From a9fcd2753d1fdfdb7559b5e90b4537aebfee1b11 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 6 Feb 2025 21:36:13 +0800 Subject: [PATCH] feat: support multiple accounts in Wallet UI (#506) * refactor: add account discover service * feat: support multiple account in SNAP * feat: watch the SNAP current account * fix: lint * feat: support multiple account --------- Co-authored-by: khanti42 --- packages/wallet-ui/package.json | 1 + packages/wallet-ui/src/App.tsx | 19 +- packages/wallet-ui/src/assets/locales/en.json | 9 + packages/wallet-ui/src/assets/locales/fr.json | 9 + .../src/components/pages/Home/Home.view.tsx | 9 +- .../PopperTooltip/PopperTooltip.view.tsx | 4 +- .../TransactionsList.view.tsx | 35 +- .../AccountDetailsModal.stories.tsx | 7 +- .../AccountDetailsModal.view.tsx | 13 +- .../AccountSwitchModal.stories.tsx | 33 + .../AccountSwitchModal.style.ts | 61 ++ .../AccountSwitchModal.view.tsx | 129 +++ .../AccountsHeader/AccountsHeader.stories.tsx | 26 + .../AccountsHeader/AccountsHeader.view.tsx | 49 ++ .../AccountsHeader/index.ts | 1 + .../HiddenAccountsList.story.tsx | 24 + .../HiddenAccountsList.view.tsx | 52 ++ .../HiddenAccountsList/index.ts | 1 + .../VisibleAccountsList.story.tsx | 26 + .../VisibleAccountsList.view.tsx | 85 ++ .../VisibleAccountsList/index.ts | 1 + .../ui/organism/AccountSwitchModal/index.ts | 1 + .../AddTokenModal/AddTokenModal.view.tsx | 8 +- .../SendSummaryModal.view.tsx | 134 ++- .../ui/organism/SideBar/SideBar.stories.tsx | 5 +- .../ui/organism/SideBar/SideBar.style.ts | 6 + .../ui/organism/SideBar/SideBar.view.tsx | 33 +- packages/wallet-ui/src/services/useSnap.ts | 82 ++ .../wallet-ui/src/services/useStarkNetSnap.ts | 808 ++++++++---------- packages/wallet-ui/src/slices/walletSlice.ts | 42 +- packages/wallet-ui/src/types/index.ts | 3 + packages/wallet-ui/src/utils/constants.ts | 8 +- 32 files changed, 1138 insertions(+), 586 deletions(-) create mode 100644 packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/AccountSwitchModal.stories.tsx create mode 100644 packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/AccountSwitchModal.style.ts create mode 100644 packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/AccountSwitchModal.view.tsx create mode 100644 packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/AccountsHeader/AccountsHeader.stories.tsx create mode 100644 packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/AccountsHeader/AccountsHeader.view.tsx create mode 100644 packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/AccountsHeader/index.ts create mode 100644 packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/HiddenAccountsList/HiddenAccountsList.story.tsx create mode 100644 packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/HiddenAccountsList/HiddenAccountsList.view.tsx create mode 100644 packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/HiddenAccountsList/index.ts create mode 100644 packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/VisibleAccountsList/VisibleAccountsList.story.tsx create mode 100644 packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/VisibleAccountsList/VisibleAccountsList.view.tsx create mode 100644 packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/VisibleAccountsList/index.ts create mode 100644 packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/index.ts create mode 100644 packages/wallet-ui/src/services/useSnap.ts diff --git a/packages/wallet-ui/package.json b/packages/wallet-ui/package.json index 9890d798..6775ab55 100644 --- a/packages/wallet-ui/package.json +++ b/packages/wallet-ui/package.json @@ -76,6 +76,7 @@ "@metamask/eslint-config-jest": "^12.1.0", "@metamask/eslint-config-nodejs": "^12.1.0", "@metamask/eslint-config-typescript": "^12.1.0", + "@metamask/utils": "^11.0.1", "@storybook/addon-actions": "^6.5.3", "@storybook/addon-essentials": "^6.5.3", "@storybook/addon-interactions": "^6.5.3", diff --git a/packages/wallet-ui/src/App.tsx b/packages/wallet-ui/src/App.tsx index e59b1ff8..849181db 100644 --- a/packages/wallet-ui/src/App.tsx +++ b/packages/wallet-ui/src/App.tsx @@ -19,13 +19,12 @@ import 'toastr2/dist/toastr.min.css'; import { NoMetamaskModal } from 'components/ui/organism/NoMetamaskModal'; import { MinVersionModal } from './components/ui/organism/MinVersionModal'; import { useHasMetamask } from 'hooks/useHasMetamask'; -import { DUMMY_ADDRESS } from 'utils/constants'; import { DeployModal } from 'components/ui/organism/DeployModal'; library.add(fas, far); function App() { - const { initSnap, getWalletData, checkConnection, loadLocale } = + const { initSnap, initWalletData, checkConnection, loadLocale } = useStarkNetSnap(); const { connected, forceReconnect, provider } = useAppSelector( (state) => state.wallet, @@ -38,11 +37,10 @@ function App() { } = useAppSelector((state) => state.modals); const { loader } = useAppSelector((state) => state.UI); const networks = useAppSelector((state) => state.networks); - const { accounts } = useAppSelector((state) => state.wallet); + const { currentAccount } = useAppSelector((state) => state.wallet); const { hasMetamask } = useHasMetamask(); - - const address = - accounts?.length > 0 ? (accounts[0] as unknown as string) : DUMMY_ADDRESS; + const chainId = networks.items?.[networks.activeNetwork]?.chainId; + const address = currentAccount.address; useEffect(() => { if (!provider) { @@ -58,12 +56,11 @@ function App() { }, [connected, forceReconnect, hasMetamask, provider]); useEffect(() => { - if (provider && networks.items.length > 0) { - const chainId = networks.items[networks.activeNetwork].chainId; - getWalletData(chainId); + if (provider && networks.items.length > 0 && chainId) { + initWalletData({ chainId }); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [networks.activeNetwork, provider]); + }, [networks.activeNetwork, provider, chainId]); useEffect(() => { if (connected) { @@ -106,7 +103,7 @@ function App() { > - + {loading && ( {loader.loadingMessage} diff --git a/packages/wallet-ui/src/assets/locales/en.json b/packages/wallet-ui/src/assets/locales/en.json index 1cb079e1..a7eb06e8 100644 --- a/packages/wallet-ui/src/assets/locales/en.json +++ b/packages/wallet-ui/src/assets/locales/en.json @@ -4,6 +4,12 @@ "aboutThisSnap": { "message": "About this snap" }, + "account": { + "message": "Compte" + }, + "accounts": { + "message": "Comptes" + }, "accountDeployedSuccessfully": { "message": "Account deployed successfully." }, @@ -297,6 +303,9 @@ }, "whatIsASnap": { "message": "What is a snap?" + }, + "youCannotHideLastAccount": { + "message": "You cannot hide the last remaining account." } } } diff --git a/packages/wallet-ui/src/assets/locales/fr.json b/packages/wallet-ui/src/assets/locales/fr.json index c38da7ce..c0144178 100644 --- a/packages/wallet-ui/src/assets/locales/fr.json +++ b/packages/wallet-ui/src/assets/locales/fr.json @@ -4,6 +4,12 @@ "aboutThisSnap": { "message": "À propos de ce snap" }, + "account": { + "message": "Compte" + }, + "accounts": { + "message": "Comptes" + }, "accountDeployedSuccessfully": { "message": "Compte déployé avec succès." }, @@ -291,6 +297,9 @@ }, "whatIsASnap": { "message": "Qu'est-ce qu'un snap ?" + }, + "youCannotHideLastAccount": { + "message": "Vous ne pouvez pas masquer le dernier compte restant." } } } diff --git a/packages/wallet-ui/src/components/pages/Home/Home.view.tsx b/packages/wallet-ui/src/components/pages/Home/Home.view.tsx index 091a6a5c..43ddb876 100644 --- a/packages/wallet-ui/src/components/pages/Home/Home.view.tsx +++ b/packages/wallet-ui/src/components/pages/Home/Home.view.tsx @@ -4,21 +4,20 @@ import { SideBar } from 'components/ui/organism/SideBar'; import { RightPart, Wrapper, NoTransactions } from './Home.style'; import { useAppSelector } from 'hooks/redux'; import { useMultiLanguage } from 'services'; -interface Props { - address: string; -} -export const HomeView = ({ address }: Props) => { +export const HomeView = () => { const { erc20TokenBalanceSelected, transactions } = useAppSelector( (state) => state.wallet, ); const loader = useAppSelector((state) => state.UI.loader); + const currentAccount = useAppSelector((state) => state.wallet.currentAccount); + const address = currentAccount.address; const { upgradeModalVisible } = useAppSelector((state) => state.modals); const { translate } = useMultiLanguage(); return ( - + {!upgradeModalVisible && Object.keys(erc20TokenBalanceSelected).length > 0 && ( diff --git a/packages/wallet-ui/src/components/ui/molecule/PopperTooltip/PopperTooltip.view.tsx b/packages/wallet-ui/src/components/ui/molecule/PopperTooltip/PopperTooltip.view.tsx index 61b91f90..1b721f75 100644 --- a/packages/wallet-ui/src/components/ui/molecule/PopperTooltip/PopperTooltip.view.tsx +++ b/packages/wallet-ui/src/components/ui/molecule/PopperTooltip/PopperTooltip.view.tsx @@ -71,7 +71,7 @@ export const PopperTooltipView = ({ }); return ( - <> +
{content} )} - +
); }; diff --git a/packages/wallet-ui/src/components/ui/molecule/TransactionsList/TransactionsList.view.tsx b/packages/wallet-ui/src/components/ui/molecule/TransactionsList/TransactionsList.view.tsx index efa01a26..699e3907 100644 --- a/packages/wallet-ui/src/components/ui/molecule/TransactionsList/TransactionsList.view.tsx +++ b/packages/wallet-ui/src/components/ui/molecule/TransactionsList/TransactionsList.view.tsx @@ -16,19 +16,23 @@ export const TransactionsListView = ({ transactions }: Props) => { const networks = useAppSelector((state) => state.networks); const wallet = useAppSelector((state) => state.wallet); const timeoutHandle = useRef(setTimeout(() => {})); + const chainId = networks.items[networks.activeNetwork]?.chainId; + const { + currentAccount, + erc20TokenBalanceSelected, + transactions: walletTransactions, + } = wallet; useEffect(() => { - const chain = networks.items[networks.activeNetwork]?.chainId; - const address = wallet.accounts?.[0] as unknown as string; - if (chain && address) { + if (chainId && erc20TokenBalanceSelected.address) { clearTimeout(timeoutHandle.current); // cancel the timeout that was in-flight timeoutHandle.current = setTimeout( () => getTransactions( - address, - wallet.erc20TokenBalanceSelected.address, + currentAccount.address, + erc20TokenBalanceSelected.address, 10, - chain, + chainId, false, true, ), @@ -37,30 +41,29 @@ export const TransactionsListView = ({ transactions }: Props) => { return () => clearTimeout(timeoutHandle.current); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [wallet.transactions]); + }, [walletTransactions]); useEffect( () => { - const chain = networks.items[networks.activeNetwork]?.chainId; - const address = wallet.accounts?.[0] as unknown as string; - if (chain && address) { + if (chainId && erc20TokenBalanceSelected.address) { clearTimeout(timeoutHandle.current); // cancel the timeout that was in-flight getTransactions( - address, - wallet.erc20TokenBalanceSelected.address, + currentAccount.address, + erc20TokenBalanceSelected.address, 10, - chain, + chainId, ); } }, // eslint-disable-next-line react-hooks/exhaustive-deps [ // eslint-disable-next-line react-hooks/exhaustive-deps - wallet.erc20TokenBalanceSelected.address, + erc20TokenBalanceSelected.address, // eslint-disable-next-line react-hooks/exhaustive-deps - wallet.erc20TokenBalanceSelected.chainId, + erc20TokenBalanceSelected.chainId, // eslint-disable-next-line react-hooks/exhaustive-deps - wallet.accounts?.[0], + currentAccount, + chainId, ], ); diff --git a/packages/wallet-ui/src/components/ui/organism/AccountDetailsModal/AccountDetailsModal.stories.tsx b/packages/wallet-ui/src/components/ui/organism/AccountDetailsModal/AccountDetailsModal.stories.tsx index 81c2bdc1..1ce303a0 100644 --- a/packages/wallet-ui/src/components/ui/organism/AccountDetailsModal/AccountDetailsModal.stories.tsx +++ b/packages/wallet-ui/src/components/ui/organism/AccountDetailsModal/AccountDetailsModal.stories.tsx @@ -8,10 +8,7 @@ export default { component: AccountDetailsModalView, } as Meta; -const address = - '0x683ec5da50476f84a5d47e822cd4dd35ae3a63c6c1f0725bf28526290d1ee13'; - -export const ContentOnly = () => ; +export const ContentOnly = () => ; export const WithModal = () => { let [isOpen, setIsOpen] = useState(false); @@ -24,7 +21,7 @@ export const WithModal = () => { showClose={false} style={{ backgroundColor: 'transparent' }} > - +
); diff --git a/packages/wallet-ui/src/components/ui/organism/AccountDetailsModal/AccountDetailsModal.view.tsx b/packages/wallet-ui/src/components/ui/organism/AccountDetailsModal/AccountDetailsModal.view.tsx index ac5e3416..37749edd 100644 --- a/packages/wallet-ui/src/components/ui/organism/AccountDetailsModal/AccountDetailsModal.view.tsx +++ b/packages/wallet-ui/src/components/ui/organism/AccountDetailsModal/AccountDetailsModal.view.tsx @@ -13,16 +13,15 @@ import { openExplorerTab } from 'utils/utils'; import { useAppSelector } from 'hooks/redux'; import { useMultiLanguage, useStarkNetSnap } from 'services'; -interface Props { - address: string; -} - -export const AccountDetailsModalView = ({ address }: Props) => { +export const AccountDetailsModalView = () => { const networks = useAppSelector((state) => state.networks); + const currentAccount = useAppSelector((state) => state.wallet.currentAccount); const { getPrivateKeyFromAddress } = useStarkNetSnap(); const { translate } = useMultiLanguage(); const chainId = networks?.items[networks.activeNetwork]?.chainId; + const address = currentAccount.address; + const addressIndex = currentAccount?.addressIndex ?? 0; return (
@@ -30,7 +29,9 @@ export const AccountDetailsModalView = ({ address }: Props) => { - {translate('myAccount')} + + {translate('account')} {addressIndex + 1} + {/* */} diff --git a/packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/AccountSwitchModal.stories.tsx b/packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/AccountSwitchModal.stories.tsx new file mode 100644 index 00000000..11be92a8 --- /dev/null +++ b/packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/AccountSwitchModal.stories.tsx @@ -0,0 +1,33 @@ +import { Meta } from '@storybook/react'; +import { AccountSwitchModalView } from './AccountSwitchModal.view'; + +export default { + title: 'Molecule/AccountAddress', + component: AccountSwitchModalView, +} as Meta; + +const wrapperStyle = { + backgroundColor: 'white', + height: '300px', + alignItems: 'center', + display: 'flex', + justifyContent: 'center', +}; + +export const Default = () => ( +
+ +
+); + +export const Full = () => ( +
+ +
+); + +export const DarkerBackground = () => ( +
+ +
+); diff --git a/packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/AccountSwitchModal.style.ts b/packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/AccountSwitchModal.style.ts new file mode 100644 index 00000000..fec71cad --- /dev/null +++ b/packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/AccountSwitchModal.style.ts @@ -0,0 +1,61 @@ +import { AccountImage } from 'components/ui/atom/AccountImage'; +import { Button } from 'components/ui/atom/Button'; +import styled from 'styled-components'; + +export const MenuSection = styled.div` + padding: 0px 10px; + display: flex; + flex-direction: column; + height: 202; + overflow-y: auto; +`; + +export const Wrapper = styled(Button).attrs((props) => ({ + fontSize: props.theme.typography.c1.fontSize, + upperCaseOnly: false, + textStyle: { + fontWeight: props.theme.typography.p1.fontWeight, + fontFamily: props.theme.typography.p1.fontFamily, + }, + iconStyle: { + fontSize: props.theme.typography.i1.fontSize, + color: props.theme.palette.grey.grey1, + }, +}))` + padding: 4px 5px; + height: 25px; + color: ${(props) => props.theme.palette.grey.black}; + border-radius: 24px; + border: 1px solid ${(props) => props.theme.palette.grey.grey3}; + + :hover { + background-color: ${(props) => props.theme.palette.grey.grey4}; + border: none; + } +`; + +export const Normal = styled.div` + font-size: ${(props) => props.theme.typography.p1.fontSize}; +`; + +export const AccountSwitchMenuItem = styled.div` + cursor: pointer; + display: flex; + align-items: center; + padding: 14px; + justify-content: space-between; +`; + +export const AccountImageStyled = styled(AccountImage)` + margin-left: ${(props) => props.theme.spacing.small}; + cursor: pointer; +`; + +export const Container = styled.div` + display: flex; + alignitems: center; +`; + +export const MenuItemText = styled.span` + ${(props) => props.theme.typography.p2}; +`; diff --git a/packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/AccountSwitchModal.view.tsx b/packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/AccountSwitchModal.view.tsx new file mode 100644 index 00000000..2e50c618 --- /dev/null +++ b/packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/AccountSwitchModal.view.tsx @@ -0,0 +1,129 @@ +import { useState } from 'react'; +import { shortenAddress, shortenDomain } from 'utils/utils'; +import { Menu } from '@headlessui/react'; +import { useAppSelector } from 'hooks/redux'; +import { useMultiLanguage, useStarkNetSnap } from 'services'; +import { + AccountSwitchMenuItem, + MenuSection, + Wrapper, +} from './AccountSwitchModal.style'; +import { MenuItems, MenuDivider } from 'components/ui/organism/Menu/Menu.style'; +import { theme } from 'theme/default'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { AccountsHeader } from './AccountsHeader'; +import { VisibleAccountsList } from './VisibleAccountsList'; +import { HiddenAccountsList } from './HiddenAccountsList'; +import { Account } from 'types'; +import Toastr from 'toastr2'; + +interface Props { + full?: boolean; + starkName?: string; +} + +export const AccountSwitchModalView = ({ full, starkName }: Props) => { + const networks = useAppSelector((state) => state.networks); + const { currentAccount, accounts } = useAppSelector((state) => state.wallet); + const { switchAccount, addNewAccount, hideAccount, unHideAccount } = + useStarkNetSnap(); + const { translate } = useMultiLanguage(); + const chainId = networks?.items[networks.activeNetwork]?.chainId; + + const [showHiddenAccounts, setShowHiddenAccounts] = useState(false); + + const onAccountVisibleClick = (account: Account) => { + setShowHiddenAccounts(false); + unHideAccount({ chainId, address: account.address }); + }; + + const onAccountHiddenClick = (account: Account) => { + if (accounts.filter((account) => account.visibility).length < 2) { + const toastr = new Toastr(); + toastr.error(translate('youCannotHideLastAccount')); + } else { + hideAccount({ + chainId, + address: account.address, + currentAddress, + }); + } + }; + + const onAccountSwitchClick = (account: Account) => { + switchAccount(chainId, account.address); + }; + + const visibleAccounts: Account[] = []; + const hiddenAccounts: Account[] = []; + for (const account of accounts) { + // account.visibility = `undefined` refer to the case when previous account state doesnt include this field + // hence we consider it is `visible` + if (account.visibility === undefined || account.visibility === true) { + visibleAccounts.push(account); + } else { + hiddenAccounts.push(account); + } + } + const currentAddress = currentAccount.address; + const displayName = full + ? starkName ?? currentAddress + : starkName + ? shortenDomain(starkName) + : shortenAddress(currentAddress); + + return ( + + + + {displayName} + + + + + {/* Accounts Header */} + + + + {/* Account Lists */} + {showHiddenAccounts ? ( + + ) : ( + + )} + + + + + await addNewAccount(chainId)} + style={{ + justifyContent: 'center', + padding: '8px 0', + textAlign: 'center', + }} + > + + + + + + + ); +}; diff --git a/packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/AccountsHeader/AccountsHeader.stories.tsx b/packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/AccountsHeader/AccountsHeader.stories.tsx new file mode 100644 index 00000000..2d24b8f2 --- /dev/null +++ b/packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/AccountsHeader/AccountsHeader.stories.tsx @@ -0,0 +1,26 @@ +import { Meta } from '@storybook/react'; +import { AccountsHeaderView } from './AccountsHeader.view'; +import { useState } from 'react'; +import { Account } from 'types'; + +export default { + title: 'Molecule/AccountsHeader', + component: AccountsHeaderView, +} as Meta; + +const accounts = [ + { address: '0x123', addressIndex: 0, visibility: false }, + { address: '0x456', addressIndex: 1, visibility: false }, +]; + +export const Default = () => { + const [showHiddenAccounts, setShowHiddenAccounts] = useState(false); + + return ( + + ); +}; diff --git a/packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/AccountsHeader/AccountsHeader.view.tsx b/packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/AccountsHeader/AccountsHeader.view.tsx new file mode 100644 index 00000000..4d373848 --- /dev/null +++ b/packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/AccountsHeader/AccountsHeader.view.tsx @@ -0,0 +1,49 @@ +import { Account } from 'types'; +import { + AccountSwitchMenuItem, + Container, + MenuSection, + Normal, +} from '../AccountSwitchModal.style'; +import { Menu } from '@headlessui/react'; +import { IconButton } from '@mui/material'; +import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; +import VisibilityIcon from '@mui/icons-material/Visibility'; +import { useMultiLanguage } from 'services'; + +interface Props { + showHiddenAccounts: boolean; + setShowHiddenAccounts: (value: boolean) => void; + hiddenAccounts: Account[]; +} + +export const AccountsHeaderView = ({ + showHiddenAccounts, + setShowHiddenAccounts, + hiddenAccounts, +}: Props) => { + const { translate } = useMultiLanguage(); + return ( + + + + + {translate('accounts')} + + {hiddenAccounts.length > 0 && ( + setShowHiddenAccounts(!showHiddenAccounts)} + size="small" + > + {showHiddenAccounts ? ( + + ) : ( + + )} + + )} + + + + ); +}; diff --git a/packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/AccountsHeader/index.ts b/packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/AccountsHeader/index.ts new file mode 100644 index 00000000..2f1db978 --- /dev/null +++ b/packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/AccountsHeader/index.ts @@ -0,0 +1 @@ +export { AccountsHeaderView as AccountsHeader } from './AccountsHeader.view'; diff --git a/packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/HiddenAccountsList/HiddenAccountsList.story.tsx b/packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/HiddenAccountsList/HiddenAccountsList.story.tsx new file mode 100644 index 00000000..8f0a7ace --- /dev/null +++ b/packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/HiddenAccountsList/HiddenAccountsList.story.tsx @@ -0,0 +1,24 @@ +import { Meta } from '@storybook/react'; +import { HiddenAccountsListView } from './HiddenAccountsList.view'; +import { Account } from 'types'; + +export default { + title: 'Molecule/HiddenAccountsList', + component: HiddenAccountsListView, +} as Meta; + +const accounts = [ + { address: '0x123', addressIndex: 0, visibility: false }, + { address: '0x456', addressIndex: 1, visibility: false }, +]; + +export const Default = () => { + return ( + + console.log(`Show account ${account.address}`) + } + /> + ); +}; diff --git a/packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/HiddenAccountsList/HiddenAccountsList.view.tsx b/packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/HiddenAccountsList/HiddenAccountsList.view.tsx new file mode 100644 index 00000000..3edadc95 --- /dev/null +++ b/packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/HiddenAccountsList/HiddenAccountsList.view.tsx @@ -0,0 +1,52 @@ +import { Account } from 'types'; +import { + AccountImageStyled, + AccountSwitchMenuItem, + MenuSection, + MenuItemText, +} from '../AccountSwitchModal.style'; +import { Menu } from '@headlessui/react'; +import { Box, IconButton } from '@mui/material'; +import VisibilityIcon from '@mui/icons-material/Visibility'; +import { shortenAddress } from 'utils/utils'; +import { useMultiLanguage } from 'services'; + +interface Props { + accounts: Account[]; + onAccountVisibleClick: (account: Account) => void; +} + +export const HiddenAccountsListView = ({ + accounts, + onAccountVisibleClick, +}: Props) => { + const { translate } = useMultiLanguage(); + return ( + + {accounts.map((account) => ( + + { + e.preventDefault(); + onAccountVisibleClick(account); + }} + > + + + +
+ {translate('account')} {account.addressIndex + 1} +
+
{shortenAddress(account.address)}
+
+
+ + + +
+
+ ))} +
+ ); +}; diff --git a/packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/HiddenAccountsList/index.ts b/packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/HiddenAccountsList/index.ts new file mode 100644 index 00000000..afa817e1 --- /dev/null +++ b/packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/HiddenAccountsList/index.ts @@ -0,0 +1 @@ +export { HiddenAccountsListView as HiddenAccountsList } from './HiddenAccountsList.view'; diff --git a/packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/VisibleAccountsList/VisibleAccountsList.story.tsx b/packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/VisibleAccountsList/VisibleAccountsList.story.tsx new file mode 100644 index 00000000..7030a1b7 --- /dev/null +++ b/packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/VisibleAccountsList/VisibleAccountsList.story.tsx @@ -0,0 +1,26 @@ +import { Meta } from '@storybook/react'; +import { VisibleAccountsListView } from './VisibleAccountsList.view'; +import { Account } from 'types'; + +export default { + title: 'Molecule/VisibleAccountsList', + component: VisibleAccountsListView, +} as Meta; + +const accounts = [ + { address: '0xabc', addressIndex: 1, visibility: true }, + { address: '0xdef', addressIndex: 2, visibility: true }, +]; + +export const Default = () => ( + + console.log(`Switch to ${account.address}`) + } + onAccountHiddenClick={(account: Account) => + console.log(`Hide account ${account.address}`) + } + /> +); diff --git a/packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/VisibleAccountsList/VisibleAccountsList.view.tsx b/packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/VisibleAccountsList/VisibleAccountsList.view.tsx new file mode 100644 index 00000000..83e872fe --- /dev/null +++ b/packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/VisibleAccountsList/VisibleAccountsList.view.tsx @@ -0,0 +1,85 @@ +import { Account } from 'types'; +import { + AccountImageStyled, + AccountSwitchMenuItem, + MenuSection, + MenuItemText, +} from '../AccountSwitchModal.style'; +import { Menu } from '@headlessui/react'; +import { Box, IconButton } from '@mui/material'; +import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; +import { theme } from 'theme/default'; +import { shortenAddress } from 'utils/utils'; +import { useMultiLanguage } from 'services'; + +interface Props { + accounts: Account[]; + currentAddress: string; + onAccountSwitchClick: (account: Account) => void; + onAccountHiddenClick: (account: Account) => void; +} + +export const VisibleAccountsListView = ({ + accounts, + currentAddress, + onAccountSwitchClick, + onAccountHiddenClick, +}: Props) => { + const { translate } = useMultiLanguage(); + return ( + + {accounts.map((account) => { + const isSelected = account.address === currentAddress; + return ( + + + onAccountSwitchClick(account) : undefined + } + > + + + +
+ {translate('account')} {account.addressIndex + 1} +
+
{shortenAddress(account.address)}
+
+
+
+ { + // Hiding accounts does not close the switch dropdown if there are still hidden accounts left. + // This allows to hide several accounts more effectively. + if (accounts.length > 2) { + e.preventDefault(); + } + onAccountHiddenClick(account); + }} + size="small" + > + + +
+
+ ); + })} +
+ ); +}; diff --git a/packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/VisibleAccountsList/index.ts b/packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/VisibleAccountsList/index.ts new file mode 100644 index 00000000..7652ed7d --- /dev/null +++ b/packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/VisibleAccountsList/index.ts @@ -0,0 +1 @@ +export { VisibleAccountsListView as VisibleAccountsList } from './VisibleAccountsList.view'; diff --git a/packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/index.ts b/packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/index.ts new file mode 100644 index 00000000..f510464a --- /dev/null +++ b/packages/wallet-ui/src/components/ui/organism/AccountSwitchModal/index.ts @@ -0,0 +1 @@ +export { AccountSwitchModalView as AccountSwitchModal } from './AccountSwitchModal.view'; diff --git a/packages/wallet-ui/src/components/ui/organism/AddTokenModal/AddTokenModal.view.tsx b/packages/wallet-ui/src/components/ui/organism/AddTokenModal/AddTokenModal.view.tsx index 38bd9798..3f011216 100644 --- a/packages/wallet-ui/src/components/ui/organism/AddTokenModal/AddTokenModal.view.tsx +++ b/packages/wallet-ui/src/components/ui/organism/AddTokenModal/AddTokenModal.view.tsx @@ -27,8 +27,8 @@ export const AddTokenModalView = ({ closeModal }: Props) => { const { translate } = useMultiLanguage(); const [enabled, setEnabled] = useState(false); const networks = useAppSelector((state) => state.networks); - const { accounts } = useAppSelector((state) => state.wallet); - const chain = networks && networks.items[networks.activeNetwork].chainId; + const { currentAccount } = useAppSelector((state) => state.wallet); + const chainId = networks?.items[networks.activeNetwork].chainId; const [isValidAddress, setIsValidAddress] = useState(false); const [fields, setFields] = useState({ address: '', @@ -103,8 +103,8 @@ export const AddTokenModalView = ({ closeModal }: Props) => { fields.name, fields.symbol, parseFloat(fields.decimal), - chain, - accounts[0] as unknown as string, + chainId, + currentAccount.address, ); if (newToken) { setErc20TokenBalance(newToken); diff --git a/packages/wallet-ui/src/components/ui/organism/Header/SendSummaryModal/SendSummaryModal.view.tsx b/packages/wallet-ui/src/components/ui/organism/Header/SendSummaryModal/SendSummaryModal.view.tsx index 24a19a29..21fabc1e 100644 --- a/packages/wallet-ui/src/components/ui/organism/Header/SendSummaryModal/SendSummaryModal.view.tsx +++ b/packages/wallet-ui/src/components/ui/organism/Header/SendSummaryModal/SendSummaryModal.view.tsx @@ -50,6 +50,7 @@ export const SendSummaryModalView = ({ selectedFeeToken, }: Props) => { const wallet = useAppSelector((state) => state.wallet); + const currentAccount = wallet.currentAccount; const [estimatingGas, setEstimatingGas] = useState(true); const [gasFees, setGasFees] = useState({ suggestedMaxFee: '0', @@ -81,40 +82,33 @@ export const SendSummaryModalView = ({ useEffect(() => { const fetchGasFee = () => { - if (wallet.accounts) { - setGasFeesError(false); - setEstimatingGas(true); - const amountBN = ethers.utils.parseUnits( - amount, - wallet.erc20TokenBalanceSelected.decimals, - ); - const callData = address + ',' + amountBN.toString() + ',0'; - estimateFees( - wallet.erc20TokenBalanceSelected.address, - ContractFuncName.Transfer, - callData, - wallet.accounts[0] as unknown as string, - chainId, - selectedFeeToken === FeeToken.STRK - ? constants.TRANSACTION_VERSION.V3 - : undefined, - ) - .then((response) => { - if (response.message && response.message.includes('Error')) { - toastr.error(translate('errorCalculatingGasFees')); - setGasFeesError(true); - } else { - setGasFees(response); - } - setEstimatingGas(false); - }) - .catch(() => { - toastr.error(translate('errorCalculatingGasFees')); - }); - } + setGasFeesError(false); + setEstimatingGas(true); + const amountBN = ethers.utils.parseUnits( + amount, + wallet.erc20TokenBalanceSelected.decimals, + ); + const callData = address + ',' + amountBN.toString() + ',0'; + estimateFees( + wallet.erc20TokenBalanceSelected.address, + ContractFuncName.Transfer, + callData, + currentAccount.address, + chainId, + selectedFeeToken === FeeToken.STRK + ? constants.TRANSACTION_VERSION.V3 + : undefined, + ) + .then((response) => { + setGasFees(response); + setEstimatingGas(false); + }) + .catch(() => { + toastr.error(translate('errorCalculatingGasFees')); + }); }; fetchGasFee(); - }, []); + }, [currentAccount]); useEffect(() => { if (gasFees?.suggestedMaxFee) { @@ -176,45 +170,43 @@ export const SendSummaryModalView = ({ }, [amount, wallet.erc20TokenBalanceSelected]); const handleConfirmClick = () => { - if (wallet.accounts) { - const amountBN = ethers.utils.parseUnits( - amount, - wallet.erc20TokenBalanceSelected.decimals, - ); - const callData = address + ',' + amountBN.toString() + ',0'; - sendTransaction( - wallet.erc20TokenBalanceSelected.address, - ContractFuncName.Transfer, - callData, - wallet.accounts[0] as unknown as string, - gasFees.suggestedMaxFee, - chainId, - selectedFeeToken, - ) - .then((result) => { - if (result) { - toastr.success(translate('transactionSentSuccessfully')); - getTransactions( - wallet.accounts[0] as unknown as string, - wallet.erc20TokenBalanceSelected.address, - 10, - chainId, - false, - true, - ).catch((err) => { - console.error( - `handleConfirmClick: error from getTransactions: ${err}`, - ); - }); - } else { - toastr.info(translate('transactionRejectedByUser')); - } - }) - .catch(() => { - toastr.error(translate('errorSendingTransaction')); - }); - closeModal && closeModal(); - } + const amountBN = ethers.utils.parseUnits( + amount, + wallet.erc20TokenBalanceSelected.decimals, + ); + const callData = address + ',' + amountBN.toString() + ',0'; + sendTransaction( + wallet.erc20TokenBalanceSelected.address, + ContractFuncName.Transfer, + callData, + currentAccount.address, + gasFees.suggestedMaxFee, + chainId, + selectedFeeToken, + ) + .then((result) => { + if (result) { + toastr.success(translate('transactionSentSuccessfully')); + getTransactions( + currentAccount.address, + wallet.erc20TokenBalanceSelected.address, + 10, + chainId, + false, + true, + ).catch((err) => { + console.error( + `handleConfirmClick: error from getTransactions: ${err}`, + ); + }); + } else { + toastr.info(translate('transactionRejectedByUser')); + } + }) + .catch(() => { + toastr.error(translate('errorSendingTransaction')); + }); + closeModal && closeModal(); }; const totalAmountDisplay = () => { diff --git a/packages/wallet-ui/src/components/ui/organism/SideBar/SideBar.stories.tsx b/packages/wallet-ui/src/components/ui/organism/SideBar/SideBar.stories.tsx index 647a460b..0c0f6c9a 100644 --- a/packages/wallet-ui/src/components/ui/organism/SideBar/SideBar.stories.tsx +++ b/packages/wallet-ui/src/components/ui/organism/SideBar/SideBar.stories.tsx @@ -6,13 +6,10 @@ export default { component: SideBarView, } as Meta; -const address = - '0x683ec5da50476f84a5d47e822cd4dd35ae3a63c6c1f0725bf28526290d1ee13'; - export const Default = () => { return (
- +
); }; diff --git a/packages/wallet-ui/src/components/ui/organism/SideBar/SideBar.style.ts b/packages/wallet-ui/src/components/ui/organism/SideBar/SideBar.style.ts index a9f2ef20..2433f9f6 100644 --- a/packages/wallet-ui/src/components/ui/organism/SideBar/SideBar.style.ts +++ b/packages/wallet-ui/src/components/ui/organism/SideBar/SideBar.style.ts @@ -39,6 +39,12 @@ export const InfoIcon = styled(RoundedIcon)` margin-right: ${(props) => props.theme.spacing.tiny2}; `; +export const CopyIcon = styled(RoundedIcon)` + cursor: pointer; + border: none; + margin-left: ${(props) => props.theme.spacing.tiny2}; +`; + export const AddTokenButton = styled(Button).attrs((props) => ({ textStyle: { fontWeight: props.theme.typography.bold.fontWeight, diff --git a/packages/wallet-ui/src/components/ui/organism/SideBar/SideBar.view.tsx b/packages/wallet-ui/src/components/ui/organism/SideBar/SideBar.view.tsx index 4cd3ffe4..6beddaf9 100644 --- a/packages/wallet-ui/src/components/ui/organism/SideBar/SideBar.view.tsx +++ b/packages/wallet-ui/src/components/ui/organism/SideBar/SideBar.view.tsx @@ -1,7 +1,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { useEffect, useRef, useState } from 'react'; import { RoundedIcon } from 'components/ui/atom/RoundedIcon'; -import { AccountAddress } from 'components/ui/molecule/AccountAddress'; +import { AccountSwitchModal } from 'components/ui/organism/AccountSwitchModal'; import { AssetsList } from 'components/ui/molecule/AssetsList'; import { PopIn } from 'components/ui/molecule/PopIn'; import { AccountDetailsModal } from '../AccountDetailsModal'; @@ -12,6 +12,7 @@ import { AccountDetailsContent, AccountImageStyled, AccountLabel, + CopyIcon, AddTokenButton, DivList, InfoIcon, @@ -23,14 +24,12 @@ import { openExplorerTab } from 'utils/utils'; import { useAppSelector } from 'hooks/redux'; import { AddTokenModal } from '../AddTokenModal'; import { useMultiLanguage, useStarkNetSnap } from 'services'; -import { DUMMY_ADDRESS } from 'utils/constants'; +import { defaultAccount } from 'utils/constants'; +import { PopperTooltip } from 'components/ui/molecule/PopperTooltip'; -interface Props { - address: string; -} - -export const SideBarView = ({ address }: Props) => { +export const SideBarView = () => { const networks = useAppSelector((state) => state.networks); + const currentAccount = useAppSelector((state) => state.wallet.currentAccount); const chainId = networks?.items[networks.activeNetwork]?.chainId; const [listOverflow, setListOverflow] = useState(false); const [infoModalOpen, setInfoModalOpen] = useState(false); @@ -40,7 +39,8 @@ export const SideBarView = ({ address }: Props) => { const { getStarkName } = useStarkNetSnap(); const { translate } = useMultiLanguage(); const [starkName, setStarkName] = useState(undefined); - + const address = currentAccount.address; + const addressIndex = currentAccount?.addressIndex ?? 0; const ref = useRef(); useEffect(() => { @@ -55,7 +55,7 @@ export const SideBarView = ({ address }: Props) => { }, [wallet.erc20TokenBalances]); useEffect(() => { - if (address && address !== DUMMY_ADDRESS) { + if (address && address !== defaultAccount.address) { getStarkName(address, chainId) .then((name) => { setStarkName(name); @@ -73,7 +73,7 @@ export const SideBarView = ({ address }: Props) => { isOpen={accountDetailsOpen} setIsOpen={setAccountDetailsOpen} > - + { - {translate('myAccount')} + + {translate('account')} {addressIndex + 1}{' '} + setInfoModalOpen(true)}>i - + + + navigator.clipboard.writeText(address)} + > + + + diff --git a/packages/wallet-ui/src/services/useSnap.ts b/packages/wallet-ui/src/services/useSnap.ts new file mode 100644 index 00000000..3c3f8d7e --- /dev/null +++ b/packages/wallet-ui/src/services/useSnap.ts @@ -0,0 +1,82 @@ +import { useAppSelector } from 'hooks/redux'; + +export type InvokeSnapParams = { + method: string; + params?: Record; +}; + +export type SnapsMetaData = { + [key in string]: { + id: string; + version: string; + enabled: boolean; + blocked: boolean; + initialPermissions: string; + }; +}; + +export const useSnap = () => { + const { provider } = useAppSelector((state) => state.wallet); + const snapId = process.env.REACT_APP_SNAP_ID + ? process.env.REACT_APP_SNAP_ID + : 'local:http://localhost:8081'; + const snapVersion = process.env.REACT_APP_SNAP_VERSION + ? process.env.REACT_APP_SNAP_VERSION + : '*'; + const minSnapVersion = process.env.REACT_APP_MIN_SNAP_VERSION + ? process.env.REACT_APP_MIN_SNAP_VERSION + : '2.0.1'; + + const invokeSnap = async ({ + method, + params, + }: InvokeSnapParams): Promise => { + try { + const response = await provider.request({ + method: 'wallet_invokeSnap', + params: { + snapId, + request: { + method, + params, + }, + }, + }); + return response as unknown as Resp; + } catch (error) { + // for now we dont have a proper logging system, so we just log to console + // eslint-disable-next-line no-console + console.error(method, error); + throw error; + } + }; + + const getInstalledSnaps = async (): Promise => { + return await provider.request({ method: 'wallet_getSnaps' }); + }; + + const requestSnap = async (): Promise => { + return await provider.request({ + method: 'wallet_requestSnaps', + params: { + [snapId]: { version: snapVersion }, + }, + }); + }; + + const ping = async (): Promise => { + await invokeSnap({ + method: 'ping', + }); + }; + + return { + ping, + requestSnap, + getInstalledSnaps, + invokeSnap, + snapId, + snapVersion, + minSnapVersion, + }; +}; diff --git a/packages/wallet-ui/src/services/useStarkNetSnap.ts b/packages/wallet-ui/src/services/useStarkNetSnap.ts index e10c93ec..4cc6500f 100644 --- a/packages/wallet-ui/src/services/useStarkNetSnap.ts +++ b/packages/wallet-ui/src/services/useStarkNetSnap.ts @@ -1,5 +1,4 @@ import { - setInfoModalVisible, setMinVersionModalVisible, setUpgradeModalVisible, setDeployModalVisible, @@ -11,11 +10,13 @@ import { upsertErc20TokenBalance, setErc20TokenBalances, setAccounts, + setCurrentAccount, setTransactions, setTransactionDeploy, setForceReconnect, setLocale, setTranslations, + updateAccount, } from '../slices/walletSlice'; import Toastr from 'toastr2'; import { @@ -24,9 +25,10 @@ import { isGTEMinVersion, getTokenBalanceWithDetails, isUserDenyError, + shortenAddress, } from '../utils/utils'; import { setWalletConnection } from '../slices/walletSlice'; -import { Network } from '../types'; +import { FeeToken, FeeTokenUnit, Network } from '../types'; import { Account } from '../types'; import { Erc20TokenBalance, Erc20Token } from '../types'; import { disableLoading, enableLoadingWithMessage } from '../slices/UISlice'; @@ -42,38 +44,26 @@ import { TransactionType, UniversalDetails, } from 'starknet'; +import { useSnap } from './useSnap'; export const useStarkNetSnap = () => { const dispatch = useAppDispatch(); + const { + invokeSnap, + getInstalledSnaps, + requestSnap, + minSnapVersion, + ping, + snapId, + } = useSnap(); const { loader } = useAppSelector((state) => state.UI); - const { transactions, erc20TokenBalances, provider } = useAppSelector( + const { erc20TokenBalances, accounts } = useAppSelector( (state) => state.wallet, ); - const snapId = process.env.REACT_APP_SNAP_ID - ? process.env.REACT_APP_SNAP_ID - : 'local:http://localhost:8081'; - const snapVersion = process.env.REACT_APP_SNAP_VERSION - ? process.env.REACT_APP_SNAP_VERSION - : '*'; - const minSnapVersion = process.env.REACT_APP_MIN_SNAP_VERSION - ? process.env.REACT_APP_MIN_SNAP_VERSION - : '2.0.1'; - const START_SCAN_INDEX = 0; - const MAX_SCANNED = 1; - const MAX_MISSED = 1; - - const defaultParam = {}; - const connectToSnap = () => { dispatch(enableLoadingWithMessage('Connecting...')); - provider - .request({ - method: 'wallet_requestSnaps', - params: { - [snapId]: { version: snapVersion }, - }, - }) + requestSnap() .then(() => { dispatch(setWalletConnection(true)); dispatch(setForceReconnect(false)); @@ -86,40 +76,24 @@ export const useStarkNetSnap = () => { const checkConnection = () => { dispatch(enableLoadingWithMessage('Connecting...')); - provider - .request({ - method: 'wallet_invokeSnap', - params: { - snapId, - request: { - method: 'ping', - params: { - ...defaultParam, - }, - }, - }, - }) + ping() .then(() => { dispatch(setWalletConnection(true)); }) - .catch((err: any) => { + .catch((error: any) => { dispatch(setWalletConnection(false)); dispatch(disableLoading()); //eslint-disable-next-line no-console - console.log(err); + console.log(error); }); }; const loadLocale = async () => { try { - const { locale } = await provider.request({ - method: 'wallet_invokeSnap', - params: { - snapId, - request: { - method: 'starkNet_getPreferences', - }, - }, + const { locale } = await invokeSnap<{ + locale: string; + }>({ + method: 'starkNet_getPreferences', }); const messages = await import(`../assets/locales/${locale}.json`); dispatch(setLocale(locale)); @@ -133,101 +107,32 @@ export const useStarkNetSnap = () => { }; const getNetworks = async () => { - const data = (await provider.request({ - method: 'wallet_invokeSnap', - params: { - snapId, - request: { - method: 'starkNet_getStoredNetworks', - params: { - ...defaultParam, - }, - }, - }, - })) as Network[]; - return data; + return await invokeSnap({ + method: 'starkNet_getStoredNetworks', + params: {}, + }); }; const getTokens = async (chainId: string) => { - const tokens = (await provider.request({ - method: 'wallet_invokeSnap', - params: { - snapId, - request: { - method: 'starkNet_getStoredErc20Tokens', - params: { - ...defaultParam, - chainId, - }, - }, - }, - })) as Erc20Token[]; - return tokens; - }; - - const recoverAccounts = async ( - chainId: string, - start: number = START_SCAN_INDEX, - maxScan: number = MAX_SCANNED, - maxMiss: number = MAX_MISSED, - ) => { - const scannedAccounts = (await provider.request({ - method: 'wallet_invokeSnap', + return await invokeSnap({ + method: 'starkNet_getStoredErc20Tokens', params: { - snapId, - request: { - method: 'starkNet_recoverAccounts', - params: { - ...defaultParam, - startScanIndex: start, - maxScanned: maxScan, - maxMissed: maxMiss, - chainId, - }, - }, + chainId, }, - })) as Account[]; - return scannedAccounts; + }); }; const getAccounts = async (chainId: string) => { - const data = (await provider.request({ - method: 'wallet_invokeSnap', + return await invokeSnap({ + method: 'starkNet_listAccounts', params: { - snapId, - request: { - method: 'starkNet_getStoredUserAccounts', - params: { - ...defaultParam, - chainId, - }, - }, - }, - })) as Account[]; - return data; - }; - - const addAccount = async (chainId: string) => { - const data = (await provider.request({ - method: 'wallet_invokeSnap', - params: { - snapId, - request: { - method: 'starkNet_createAccount', - params: { - ...defaultParam, - addressIndex: START_SCAN_INDEX, - chainId, - deploy: false, - }, - }, + chainId, }, - })) as Account; - return data; + }); }; const oldVersionDetected = async () => { - const snaps = await provider.request({ method: 'wallet_getSnaps' }); + const snaps = await getInstalledSnaps(); if (typeof snaps[snapId]?.version !== 'undefined') { // console.log(`snaps[snapId][version]: ${snaps[snapId]?.version}`); // console.log(`snaps[snapId][version].split('_')[0]: ${snaps[snapId]?.version?.split('-')?.[0]}`); @@ -248,15 +153,22 @@ export const useStarkNetSnap = () => { dispatch(enableLoadingWithMessage('Initializing wallet ...')); } try { - const nets = await getNetworks(); - if (nets.length === 0) { + const networks = await getNetworks(); + if (networks.length === 0) { + console.error('No networks found'); return; } - const net = await getCurrentNetwork(); - const idx = nets.findIndex((e) => e.chainId === net.chainId); + dispatch(setNetworks(networks)); + + const currentNetwork = await getCurrentNetwork(); + const idx = networks.findIndex( + (network) => network.chainId === currentNetwork.chainId, + ); dispatch(setActiveNetwork(idx)); - const chainId = net.chainId; - await getWalletData(chainId, nets); + + await initWalletData({ + chainId: currentNetwork.chainId, + }); } catch (err: any) { if (err.code && err.code === 4100) { const toastr = new Toastr(); @@ -276,80 +188,136 @@ export const useStarkNetSnap = () => { } }; - const getWalletData = async (chainId: string, networks?: Network[]) => { - if (!loader.isLoading && !networks) { + const initWalletData = async ({ + account, + chainId, + }: { + account?: Account; + chainId: string; + }) => { + if (!loader.isLoading) { dispatch(enableLoadingWithMessage('Getting network data ...')); } - const tokens = await getTokens(chainId); - let acc: Account[] | Account = await recoverAccounts(chainId); - let upgradeRequired = false; - let deployRequired = false; - deployRequired = - (Array.isArray(acc) - ? acc[0].deployRequired - : (acc as Account).deployRequired) ?? false; - if (!acc || acc.length === 0 || (!acc[0].publicKey && !deployRequired)) { - acc = await addAccount(chainId); - } else { - upgradeRequired = - (Array.isArray(acc) - ? acc[0].upgradeRequired - : (acc as Account).upgradeRequired) ?? false; + + let currentAccount = account; + if (!currentAccount) { + currentAccount = await getCurrentAccount(chainId); + } + + await setAccount(chainId, currentAccount); + + const { address } = currentAccount; + + await initTokensAndBalances(chainId, address); + + dispatch(disableLoading()); + }; + + const hideAccount = async ({ + chainId, + address, + currentAddress, + }: { + chainId: string; + address: string; + currentAddress: string; + }) => { + try { + if (!loader.isLoading) { + dispatch( + enableLoadingWithMessage(`Hiding account ${shortenAddress(address)}`), + ); + } + const account = await toggleAccountVisibility(chainId, address, false); + dispatch(updateAccount({ address, updates: { visibility: false } })); + if (account.address !== currentAddress) { + await initWalletData({ + account, + chainId, + }); + } + } catch (error) { + const toastr = new Toastr(); + toastr.error('Failed to hide the account'); + } finally { + dispatch(disableLoading()); + } + }; + + const unHideAccount = async ({ + chainId, + address, + }: { + chainId: string; + address: string; + }) => { + if (!loader.isLoading) { + dispatch(enableLoadingWithMessage(`Loading...`)); + } + try { + await toggleAccountVisibility(chainId, address, true); + dispatch(updateAccount({ address, updates: { visibility: true } })); + } catch (err) { + const toastr = new Toastr(); + toastr.error('Failed to show the account'); + } finally { + dispatch(disableLoading()); } + }; + + const setAccount = async (chainId: string, currentAccount: Account) => { + const { upgradeRequired, deployRequired } = currentAccount; + + dispatch(setCurrentAccount(currentAccount)); + + // if no accounts from state, we fetch the accounts from snap + if (!accounts || accounts.length === 0) { + const accounts = await getAccounts(chainId); + dispatch(setAccounts(accounts)); + } + + // TODO: hardcode to set the info modal visible, + // but it should only visible when the account is not deployed + // dispatch(setInfoModalVisible(true)); + dispatch(setUpgradeModalVisible(upgradeRequired)); + dispatch(setDeployModalVisible(deployRequired)); + }; - const accountAddr = Array.isArray(acc) ? acc[0].address : acc.address; + const setErc20TokenBalance = (erc20TokenBalance: Erc20TokenBalance) => { + dispatch(setErc20TokenBalanceSelected(erc20TokenBalance)); + }; + + const initTokensAndBalances = async (chainId: string, address: string) => { + const tokens = await getTokens(chainId); // Get all tokens balance, USD value, and format them into Erc20TokenBalance type const tokensWithBalances: Erc20TokenBalance[] = await Promise.all( tokens.map(async (token) => { - const balance = await getTokenBalance( - token.address, - accountAddr, - chainId, - ); + const balance = await getTokenBalance(token.address, address, chainId); const usdPrice = await getAssetPriceUSD(token); return await getTokenBalanceWithDetails(balance, token, usdPrice); }), ); - if (networks) { - dispatch(setNetworks(networks)); - } + dispatch(setErc20TokenBalances(tokensWithBalances)); - dispatch(setAccounts(acc)); + if (tokensWithBalances.length > 0) { setErc20TokenBalance(tokensWithBalances[0]); } - if (!Array.isArray(acc)) { - dispatch(setInfoModalVisible(true)); - } - dispatch(setUpgradeModalVisible(upgradeRequired && !deployRequired)); - dispatch(setDeployModalVisible(deployRequired)); - dispatch(disableLoading()); - }; - - const setErc20TokenBalance = (erc20TokenBalance: Erc20TokenBalance) => { - dispatch(setErc20TokenBalanceSelected(erc20TokenBalance)); }; async function getPrivateKeyFromAddress(address: string, chainId: string) { try { - await provider.request({ - method: 'wallet_invokeSnap', + await invokeSnap({ + method: 'starkNet_displayPrivateKey', params: { - snapId, - request: { - method: 'starkNet_displayPrivateKey', - params: { - ...defaultParam, - address: address, - chainId, - }, - }, + address: address, + chainId, }, }); - } catch (err) { - if (!isUserDenyError(err)) { - throw err; + } catch (error) { + if (!isUserDenyError(error)) { + throw error; } } } @@ -362,38 +330,30 @@ export const useStarkNetSnap = () => { chainId: string, transactionVersion?: typeof constants.TRANSACTION_VERSION.V3, ) { - try { - const invocations: Invocations = [ - { - type: TransactionType.INVOKE, - payload: { - contractAddress, - entrypoint: contractFuncName, - calldata: contractCallData.split(',').map((ele) => ele.trim()), - }, - }, - ]; - const response = await provider.request({ - method: 'wallet_invokeSnap', - params: { - snapId, - request: { - method: 'starkNet_estimateFee', - params: { - ...defaultParam, - address, - invocations, - details: { version: transactionVersion }, - chainId, - }, - }, + const invocations: Invocations = [ + { + type: TransactionType.INVOKE, + payload: { + contractAddress, + entrypoint: contractFuncName, + calldata: contractCallData.split(',').map((ele) => ele.trim()), }, - }); - return response; - } catch (err) { - //eslint-disable-next-line no-console - console.error(err); - } + }, + ]; + + return await invokeSnap<{ + suggestedMaxFee: string; + unit: FeeTokenUnit; + includeDeploy: boolean; + }>({ + method: 'starkNet_estimateFee', + params: { + address, + invocations, + details: { version: transactionVersion }, + chainId, + }, + }); } async function sendTransaction( @@ -414,33 +374,29 @@ export const useStarkNetSnap = () => { calldata: contractCallData.split(',').map((ele) => ele.trim()), }, ]; - const response = await provider.request({ - method: 'wallet_invokeSnap', + + const response = await invokeSnap<{ + transaction_hash: string; + }>({ + method: 'starkNet_executeTxn', params: { - snapId, - request: { - method: 'starkNet_executeTxn', - params: { - ...defaultParam, - address, - calls, - details: { - version: - feeToken === 'STRK' - ? constants.TRANSACTION_VERSION.V3 - : undefined, - maxFee, - } as UniversalDetails, - chainId, - }, - }, + address, + calls, + details: { + version: + feeToken === FeeToken.STRK + ? constants.TRANSACTION_VERSION.V3 + : undefined, + maxFee, + } as UniversalDetails, + chainId, }, }); return response; - } catch (err) { - if (!isUserDenyError(err)) { - throw err; + } catch (error) { + if (!isUserDenyError(error)) { + throw error; } } finally { dispatch(disableLoading()); @@ -451,52 +407,29 @@ export const useStarkNetSnap = () => { transactionHash: string, chainId: string, ) => { - try { - const response = await provider.request({ - method: 'wallet_invokeSnap', - params: { - snapId, - request: { - method: 'starkNet_getTransactionStatus', - params: { - ...defaultParam, - transactionHash, - chainId, - }, - }, - }, - }); - return response; - } catch (err) { - //eslint-disable-next-line no-console - console.error(err); - } + return await invokeSnap<{ + executionStatus?: string; + finalityStatus?: string; + }>({ + method: 'starkNet_getTransactionStatus', + params: { + transactionHash, + chainId, + }, + }); }; - const readContract = async ( + const readContract = async ( contractAddress: string, contractFuncName: string, ) => { - try { - const response = await provider.request({ - method: 'wallet_invokeSnap', - params: { - snapId, - request: { - method: 'starkNet_getValue', - params: { - ...defaultParam, - contractAddress, - contractFuncName, - }, - }, - }, - }); - return response; - } catch (err) { - //eslint-disable-next-line no-console - console.error(err); - } + return await invokeSnap({ + method: 'starkNet_getValue', + params: { + contractAddress, + contractFuncName, + }, + }); }; const deployAccount = async ( @@ -506,26 +439,21 @@ export const useStarkNetSnap = () => { ) => { dispatch(enableLoadingWithMessage('Deploying account...')); try { - const response = await provider.request({ - method: 'wallet_invokeSnap', + const response = await invokeSnap<{ + transaction_hash: string; + }>({ + method: 'starkNet_createAccountLegacy', params: { - snapId, - request: { - method: 'starkNet_createAccountLegacy', - params: { - ...defaultParam, - contractAddress, - maxFee, - chainId, - deploy: true, - }, - }, + contractAddress, + maxFee, + chainId, + deploy: true, }, }); return response; - } catch (err) { - if (!isUserDenyError(err)) { - throw err; + } catch (error) { + if (!isUserDenyError(error)) { + throw error; } return false; } finally { @@ -540,26 +468,20 @@ export const useStarkNetSnap = () => { ) => { dispatch(enableLoadingWithMessage('Upgrading account...')); try { - const response = await provider.request({ - method: 'wallet_invokeSnap', + const response = await invokeSnap<{ + transaction_hash: string; + }>({ + method: 'starkNet_upgradeAccContract', params: { - snapId, - request: { - method: 'starkNet_upgradeAccContract', - params: { - ...defaultParam, - contractAddress, - maxFee, - chainId, - }, - }, + contractAddress, + maxFee, + chainId, }, }); - return response; - } catch (err) { - if (!isUserDenyError(err)) { - throw err; + } catch (error) { + if (!isUserDenyError(error)) { + throw error; } return false; } finally { @@ -580,39 +502,17 @@ export const useStarkNetSnap = () => { } try { - const data = await provider.request({ - method: 'wallet_invokeSnap', + const data = await invokeSnap>({ + method: 'starkNet_getTransactions', params: { - snapId, - request: { - method: 'starkNet_getTransactions', - params: { - ...defaultParam, - senderAddress, - contractAddress, - txnsInLastNumOfDays, - chainId, - }, - }, + senderAddress, + contractAddress, + txnsInLastNumOfDays, + chainId, }, }); let storedTxns = data; - if (onlyFromState) { - // Filter out stored txns that are not found in the retrieved txns - const filteredTxns = transactions.filter((txn: Transaction) => { - return !storedTxns.find((storedTxn: Transaction) => - ethers.BigNumber.from(storedTxn.txnHash).eq( - ethers.BigNumber.from(txn.txnHash), - ), - ); - }); - - // sort in timestamp descending order - storedTxns = [...storedTxns, ...filteredTxns].sort( - (a: Transaction, b: Transaction) => b.timestamp - a.timestamp, - ); - } //Set the deploy transaction const deployTransaction = storedTxns.find( @@ -624,14 +524,13 @@ export const useStarkNetSnap = () => { dispatch(setTransactions(storedTxns)); + return data; + } catch (error) { + dispatch(setTransactions([])); + } finally { if (showLoading) { dispatch(disableLoading()); } - return data; - } catch (err) { - dispatch(disableLoading()); - dispatch(setTransactions([])); - console.error(err); } }; @@ -645,21 +544,14 @@ export const useStarkNetSnap = () => { ) => { dispatch(enableLoadingWithMessage('Adding Token...')); try { - await provider.request({ - method: 'wallet_invokeSnap', + await invokeSnap({ + method: 'starkNet_addErc20Token', params: { - snapId, - request: { - method: 'starkNet_addErc20Token', - params: { - ...defaultParam, - tokenAddress, - tokenName, - tokenSymbol, - tokenDecimals, - chainId, - }, - }, + tokenAddress, + tokenName, + tokenSymbol, + tokenDecimals, + chainId, }, }); @@ -685,9 +577,9 @@ export const useStarkNetSnap = () => { ); dispatch(upsertErc20TokenBalance(tokenWithBalance)); return tokenWithBalance; - } catch (err) { - if (!isUserDenyError(err)) { - throw err; + } catch (error) { + if (!isUserDenyError(error)) { + throw error; } return null; } finally { @@ -731,27 +623,20 @@ export const useStarkNetSnap = () => { chainId: string, ) => { try { - const response = await provider.request({ - method: 'wallet_invokeSnap', + const response = await invokeSnap<{ + balancePending: string; + }>({ + method: 'starkNet_getErc20TokenBalance', params: { - snapId, - request: { - method: 'starkNet_getErc20TokenBalance', - params: { - ...defaultParam, - tokenAddress, - userAddress, - chainId, - }, - }, + tokenAddress, + userAddress, + chainId, }, }); return { balance: BigNumber.from(response.balancePending), }; - } catch (err) { - //eslint-disable-next-line no-console - console.error(err); + } catch (error) { return { balance: BigNumber.from('0x0'), }; @@ -840,13 +725,11 @@ export const useStarkNetSnap = () => { try { const executeFn = async (): Promise => { // read contract to check if upgrade is required - const resp = await readContract(accountAddress, 'getVersion'); + const resp = await readContract(accountAddress, 'getVersion'); if (!resp || !resp[0]) { return false; } - // recover accounts to update snap state - await recoverAccounts(chainId); return true; }; @@ -886,7 +769,7 @@ export const useStarkNetSnap = () => { try { const executeFn = async (): Promise => { // read contract to check if upgrade is required - const resp = await readContract(accountAddress, 'getVersion'); + const resp = await readContract(accountAddress, 'getVersion'); if (!resp || !resp[0]) { return false; } @@ -895,8 +778,6 @@ export const useStarkNetSnap = () => { return false; } - // recover accounts to update snap state - await recoverAccounts(chainId); return true; }; @@ -917,85 +798,121 @@ export const useStarkNetSnap = () => { const switchNetwork = async (chainId: string) => { dispatch(enableLoadingWithMessage('Switching Network...')); try { - const result = await provider.request({ - method: 'wallet_invokeSnap', + return await invokeSnap({ + method: 'starkNet_switchNetwork', params: { - snapId, - request: { - method: 'starkNet_switchNetwork', - params: { - ...defaultParam, - chainId, - }, - }, + chainId, }, }); - dispatch(disableLoading()); - return result; - } catch (err) { + } catch (error) { dispatch(disableLoading()); return false; } }; const getCurrentNetwork = async () => { - try { - return await provider.request({ - method: 'wallet_invokeSnap', - params: { - snapId, - request: { - method: 'starkNet_getCurrentNetwork', - params: { - ...defaultParam, - }, - }, - }, - }); - } catch (err) { - throw err; - } + return await invokeSnap({ + method: 'starkNet_getCurrentNetwork', + }); }; const getStarkName = async (userAddress: string, chainId: string) => { + return await invokeSnap({ + method: 'starkNet_getStarkName', + params: { + userAddress, + chainId, + }, + }); + }; + + const getAddrFromStarkName = async (starkName: string, chainId: string) => { + return await invokeSnap({ + method: 'starkNet_getAddrFromStarkName', + params: { + starkName, + chainId, + }, + }); + }; + + const addNewAccount = async (chainId: string) => { + dispatch(enableLoadingWithMessage('Adding new account...')); try { - return await provider.request({ - method: 'wallet_invokeSnap', + const account = await invokeSnap({ + method: 'starkNet_addAccount', params: { - snapId, - request: { - method: 'starkNet_getStarkName', - params: { - ...defaultParam, - userAddress, - chainId, - }, - }, + chainId, }, }); - } catch (err) { - throw err; + + await initWalletData({ + account, + chainId, + }); + + // push the current account into state + dispatch(setAccounts(account)); + + return account; + } catch (err: any) { + const toastr = new Toastr(); + toastr.error(err.message as unknown as string); + } finally { + dispatch(disableLoading()); } }; - const getAddrFromStarkName = async (starkName: string, chainId: string) => { + const getCurrentAccount = async (chainId: string) => { + return await invokeSnap({ + method: 'starkNet_getCurrentAccount', + params: { + chainId, + }, + }); + }; + + const toggleAccountVisibility = async ( + chainId: string, + address: string, + visibility: boolean, + ) => { + return await invokeSnap({ + method: 'starkNet_toggleAccountVisibility', + params: { + chainId, + address, + visibility, + }, + }); + }; + + const switchAccount = async (chainId: string, address: string) => { + dispatch( + enableLoadingWithMessage( + `Switching Account to ${shortenAddress(address)}`, + ), + ); try { - return await provider.request({ - method: 'wallet_invokeSnap', + const account = await invokeSnap({ + method: 'starkNet_switchAccount', params: { - snapId, - request: { - method: 'starkNet_getAddrFromStarkName', - params: { - ...defaultParam, - starkName, - chainId, - }, - }, + chainId, + address, }, }); - } catch (err) { - throw err; + + await initWalletData({ + account, + chainId, + }); + + return account; + } catch (err: any) { + const toastr = new Toastr(); + toastr.error(err.message as unknown as string); + } finally { + dispatch(disableLoading()); } }; @@ -1004,7 +921,13 @@ export const useStarkNetSnap = () => { loadLocale, getNetworks, getAccounts, - addAccount, + hideAccount, + unHideAccount, + switchAccount, + getCurrentAccount, + addNewAccount, + toggleAccountVisibility, + setAccount, setErc20TokenBalance, getPrivateKeyFromAddress, estimateFees, @@ -1013,7 +936,6 @@ export const useStarkNetSnap = () => { deployAccount, getTransactions, getTransactionStatus, - recoverAccounts, waitForTransaction, waitForAccountUpdate, waitForAccountCreation, @@ -1023,7 +945,7 @@ export const useStarkNetSnap = () => { getTokens, checkConnection, initSnap, - getWalletData, + initWalletData, refreshTokensUSDPrice, readContract, switchNetwork, diff --git a/packages/wallet-ui/src/slices/walletSlice.ts b/packages/wallet-ui/src/slices/walletSlice.ts index 06b29a48..2a1f3bf3 100644 --- a/packages/wallet-ui/src/slices/walletSlice.ts +++ b/packages/wallet-ui/src/slices/walletSlice.ts @@ -3,6 +3,7 @@ import { Account, Locale } from 'types'; import { Erc20TokenBalance } from 'types'; import { Transaction } from 'types'; import { ethers } from 'ethers'; +import { defaultAccount } from 'utils/constants'; import defaultLocale from '../assets/locales/en.json'; export interface WalletState { @@ -12,6 +13,7 @@ export interface WalletState { translations: Locale; forceReconnect: boolean; accounts: Account[]; + currentAccount: Account; erc20TokenBalances: Erc20TokenBalance[]; erc20TokenBalanceSelected: Erc20TokenBalance; transactions: Transaction[]; @@ -26,6 +28,7 @@ const initialState: WalletState = { translations: defaultLocale.messages, forceReconnect: false, accounts: [], + currentAccount: defaultAccount, erc20TokenBalances: [], erc20TokenBalanceSelected: {} as Erc20TokenBalance, transactions: [], @@ -52,13 +55,39 @@ export const walletSlice = createSlice({ setForceReconnect: (state, { payload }) => { state.forceReconnect = payload; }, - setAccounts: (state, { payload }) => { - if (Array.isArray(payload)) { - state.accounts = payload.map((account) => account.address); - } else { - state.accounts.push(payload.address); + setAccounts: ( + state, + { + payload, + }: { + payload: Account | Account[]; + }, + ) => { + const accountsToInsert = Array.isArray(payload) ? payload : [payload]; + + const accountSet = new Set( + state.accounts.map((account) => account.address), + ); + + for (const account of accountsToInsert) { + if (!accountSet.has(account.address)) { + state.accounts.push(account); + } } }, + updateAccount: ( + state, + { payload }: { payload: { address: string; updates: Partial } }, + ) => { + state.accounts = state.accounts.map((account) => + account.address === payload.address + ? { ...account, ...payload.updates } + : account, + ); + }, + setCurrentAccount: (state, { payload }: { payload: Account }) => { + state.currentAccount = payload; + }, setErc20TokenBalances: (state, { payload }) => { state.erc20TokenBalances = payload; }, @@ -105,6 +134,7 @@ export const walletSlice = createSlice({ }, clearAccounts: (state) => { state.accounts = []; + state.currentAccount = defaultAccount; }, resetWallet: (state) => { return { @@ -119,7 +149,9 @@ export const walletSlice = createSlice({ export const { setWalletConnection, setForceReconnect, + setCurrentAccount, setAccounts, + updateAccount, clearAccounts, setErc20TokenBalances, setErc20TokenBalanceSelected, diff --git a/packages/wallet-ui/src/types/index.ts b/packages/wallet-ui/src/types/index.ts index bbda7066..18499fe5 100644 --- a/packages/wallet-ui/src/types/index.ts +++ b/packages/wallet-ui/src/types/index.ts @@ -32,6 +32,9 @@ export type Account = { publicKey: string; upgradeRequired: boolean; deployRequired: boolean; + chainId: string; + addressIndex: number; + visibility?: boolean; }; export type Network = { diff --git a/packages/wallet-ui/src/utils/constants.ts b/packages/wallet-ui/src/utils/constants.ts index 1c79c6c1..8141e709 100644 --- a/packages/wallet-ui/src/utils/constants.ts +++ b/packages/wallet-ui/src/utils/constants.ts @@ -1,5 +1,5 @@ import { constants } from 'starknet'; -import { FeeToken } from 'types'; +import { Account, FeeToken } from 'types'; // TODO: Importing directly from constants when upgrading to starknet.js v6 export const SEPOLIA_CHAINID = '0x534e5f5345504f4c4941'; @@ -61,7 +61,11 @@ export const TOKEN_BALANCE_REFRESH_FREQUENCY = 60000; export const TIMEOUT_DURATION = 10000; export const MIN_ACC_CONTRACT_VERSION = [0, 3, 0]; -export const DUMMY_ADDRESS = '0xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'; + +export const defaultAccount = { + address: '0xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + addressIndex: 0, +} as Account; export const DEFAULT_FEE_TOKEN = FeeToken.ETH;