From d472cd6d2706030263fd46afce0aacf7864b71e7 Mon Sep 17 00:00:00 2001 From: khanti42 Date: Fri, 17 Jan 2025 13:12:16 +0100 Subject: [PATCH 1/2] feat: [Wallet-UI] account selection dropdown (#482) * feat: add account service * chore: fix lint * chore: update account contract discovery logic * fix: code comment * chore: add discovery logic description * fix: lint * feat: add account service factory * fix: rename deployPayload * chore: adopt account discovery in RPCs * chore: update execute txn test * fix: execute test * fix: account discovery bug * fix: discovery logic * feat: add `AddAccount` RPC * feat: add max account create limit * fix: add `isMaxAccountLimitExceeded` unit test * feat: add account ui * fix: account deploy require result * fix: lint * fix: lint * feat: add get current account RPC * feat: add list accounts rpc * fix: lint * feat: add swtich account rpc * fix: comments on add account icon * chore: set new account to current in snap * fix: lint * feat: add manage multi account hooks * fix: remove non exist component * fix: var naming in UI * chore: update logger mocking * chore: update setAccounts logic in UI * chore: remove duplicate when set account from UI * chore: remove non necessary account array in UI * feat: account selection dropdown * chore: connect ui to snap for switch account * chore: fix comments * chore: lint + prettier * chore: fix bugs qa * chore: fix comments * chore: revert snap changes * chore: revert snap changes --------- Co-authored-by: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> --- .../AccountSwitchModal.stories.tsx | 57 ++++++++++ .../AccountSwitchModal.style.ts | 26 +++++ .../AccountSwitchModal.view.tsx | 104 ++++++++++++++++++ .../ui/molecule/AccountSwitchModal/index.ts | 1 + .../PopperTooltip/PopperTooltip.view.tsx | 4 +- .../ui/organism/SideBar/SideBar.style.ts | 3 +- .../ui/organism/SideBar/SideBar.view.tsx | 22 +++- .../wallet-ui/src/services/useStarkNetSnap.ts | 33 +++++- 8 files changed, 236 insertions(+), 14 deletions(-) create mode 100644 packages/wallet-ui/src/components/ui/molecule/AccountSwitchModal/AccountSwitchModal.stories.tsx create mode 100644 packages/wallet-ui/src/components/ui/molecule/AccountSwitchModal/AccountSwitchModal.style.ts create mode 100644 packages/wallet-ui/src/components/ui/molecule/AccountSwitchModal/AccountSwitchModal.view.tsx create mode 100644 packages/wallet-ui/src/components/ui/molecule/AccountSwitchModal/index.ts diff --git a/packages/wallet-ui/src/components/ui/molecule/AccountSwitchModal/AccountSwitchModal.stories.tsx b/packages/wallet-ui/src/components/ui/molecule/AccountSwitchModal/AccountSwitchModal.stories.tsx new file mode 100644 index 00000000..d55fadab --- /dev/null +++ b/packages/wallet-ui/src/components/ui/molecule/AccountSwitchModal/AccountSwitchModal.stories.tsx @@ -0,0 +1,57 @@ +import { Meta } from '@storybook/react'; +import { AccountSwitchModalView } from './AccountSwitchModal.view'; + +export default { + title: 'Molecule/AccountAddress', + component: AccountSwitchModalView, +} as Meta; + +const address = + '0x683ec5da50476f84a5d47e822cd4dd35ae3a63c6c1f0725bf28526290d1ee13'; +const wrapperStyle = { + backgroundColor: 'white', + height: '300px', + alignItems: 'center', + display: 'flex', + justifyContent: 'center', +}; + +const accounts = ['0x123...abcd', '0x456...efgh', '0x789...ijkl']; + +export const Default = () => ( +
+ +
+); + +export const TooltipTop = () => ( +
+ +
+); + +export const Full = () => ( +
+ +
+); + +export const DarkerBackground = () => ( +
+ +
+); diff --git a/packages/wallet-ui/src/components/ui/molecule/AccountSwitchModal/AccountSwitchModal.style.ts b/packages/wallet-ui/src/components/ui/molecule/AccountSwitchModal/AccountSwitchModal.style.ts new file mode 100644 index 00000000..1c14edb8 --- /dev/null +++ b/packages/wallet-ui/src/components/ui/molecule/AccountSwitchModal/AccountSwitchModal.style.ts @@ -0,0 +1,26 @@ +import { Button } from 'components/ui/atom/Button'; +import styled from 'styled-components'; + +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; + } +`; diff --git a/packages/wallet-ui/src/components/ui/molecule/AccountSwitchModal/AccountSwitchModal.view.tsx b/packages/wallet-ui/src/components/ui/molecule/AccountSwitchModal/AccountSwitchModal.view.tsx new file mode 100644 index 00000000..9e56e8c6 --- /dev/null +++ b/packages/wallet-ui/src/components/ui/molecule/AccountSwitchModal/AccountSwitchModal.view.tsx @@ -0,0 +1,104 @@ +import { shortenAddress, shortenDomain } from 'utils/utils'; +import { Wrapper } from './AccountSwitchModal.style'; +import { Menu } from '@headlessui/react'; +import { + MenuItems, + MenuSection, + NetworkMenuItem, + MenuItemText, +} from 'components/ui/organism/Menu/Menu.style'; +import { Radio } from '@mui/material'; +import { theme } from 'theme/default'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { useAppSelector } from 'hooks/redux'; +import { useStarkNetSnap } from 'services'; + +interface Props { + currentAddress: string; + accounts: string[]; + full?: boolean; + starkName?: string; +} + +export const AccountSwitchModalView = ({ + currentAddress, + accounts, + full, + starkName, +}: Props) => { + const networks = useAppSelector((state) => state.networks); + const { switchAccount, initWalletData, addNewAccount } = useStarkNetSnap(); + const chainId = networks?.items[networks.activeNetwork]?.chainId; + + const changeAccount = async (currentAddress: string) => { + const account = await switchAccount(chainId, currentAddress); + await initWalletData({ + account, + chainId, + }); + }; + + return ( + + + + {full + ? starkName ?? currentAddress + : starkName + ? shortenDomain(starkName) + : shortenAddress(currentAddress)} + + + + + {/* Account List */} + + +
Accounts
+
+
+ + {accounts.map((account) => ( + + changeAccount(account)}> + + + {full ? account : shortenAddress(account)} + + + + ))} + + + + + await addNewAccount(chainId)} + style={{ + justifyContent: 'center', + padding: '8px 0', + textAlign: 'center', + }} + > + + + + +
+
+ ); +}; diff --git a/packages/wallet-ui/src/components/ui/molecule/AccountSwitchModal/index.ts b/packages/wallet-ui/src/components/ui/molecule/AccountSwitchModal/index.ts new file mode 100644 index 00000000..f510464a --- /dev/null +++ b/packages/wallet-ui/src/components/ui/molecule/AccountSwitchModal/index.ts @@ -0,0 +1 @@ +export { AccountSwitchModalView as AccountSwitchModal } from './AccountSwitchModal.view'; 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/organism/SideBar/SideBar.style.ts b/packages/wallet-ui/src/components/ui/organism/SideBar/SideBar.style.ts index fb1d015f..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,8 +39,9 @@ export const InfoIcon = styled(RoundedIcon)` margin-right: ${(props) => props.theme.spacing.tiny2}; `; -export const AddIcon = styled(RoundedIcon)` +export const CopyIcon = styled(RoundedIcon)` cursor: pointer; + border: none; margin-left: ${(props) => props.theme.spacing.tiny2}; `; 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 b2236945..17f52526 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/molecule/AccountSwitchModal'; import { AssetsList } from 'components/ui/molecule/AssetsList'; import { PopIn } from 'components/ui/molecule/PopIn'; import { AccountDetailsModal } from '../AccountDetailsModal'; @@ -12,7 +12,7 @@ import { AccountDetailsContent, AccountImageStyled, AccountLabel, - AddIcon, + CopyIcon, AddTokenButton, DivList, InfoIcon, @@ -25,6 +25,7 @@ import { useAppSelector } from 'hooks/redux'; import { AddTokenModal } from '../AddTokenModal'; import { useStarkNetSnap } from 'services'; import { DUMMY_ADDRESS } from 'utils/constants'; +import { PopperTooltip } from 'components/ui/molecule/PopperTooltip'; interface Props { address: string; @@ -32,13 +33,14 @@ interface Props { export const SideBarView = ({ address }: Props) => { const networks = useAppSelector((state) => state.networks); + const accounts = useAppSelector((state) => state.wallet.accounts); const chainId = networks?.items[networks.activeNetwork]?.chainId; const [listOverflow, setListOverflow] = useState(false); const [infoModalOpen, setInfoModalOpen] = useState(false); const [accountDetailsOpen, setAccountDetailsOpen] = useState(false); const wallet = useAppSelector((state) => state.wallet); const [addTokenOpen, setAddTokenOpen] = useState(false); - const { getStarkName, addNewAccount } = useStarkNetSnap(); + const { getStarkName } = useStarkNetSnap(); const [starkName, setStarkName] = useState(undefined); const ref = useRef(); @@ -114,8 +116,18 @@ export const SideBarView = ({ address }: Props) => { My account setInfoModalOpen(true)}>i - - await addNewAccount(chainId)}>+ + + + navigator.clipboard.writeText(address)} + > + + + diff --git a/packages/wallet-ui/src/services/useStarkNetSnap.ts b/packages/wallet-ui/src/services/useStarkNetSnap.ts index 056187f7..5cfa4782 100644 --- a/packages/wallet-ui/src/services/useStarkNetSnap.ts +++ b/packages/wallet-ui/src/services/useStarkNetSnap.ts @@ -23,6 +23,7 @@ import { isGTEMinVersion, getTokenBalanceWithDetails, isUserDenyError, + shortenAddress, } from '../utils/utils'; import { setWalletConnection } from '../slices/walletSlice'; import { FeeToken, FeeTokenUnit, Network } from '../types'; @@ -799,13 +800,33 @@ export const useStarkNetSnap = () => { }); }; - const switchAccount = async (chainId: string) => { - return await invokeSnap({ - method: 'starkNet_switchtAccount', - params: { + const switchAccount = async (chainId: string, address: string) => { + dispatch( + enableLoadingWithMessage( + `Switching Account to ${shortenAddress(address)}`, + ), + ); + try { + const account = await invokeSnap({ + method: 'starkNet_swtichAccount', + params: { + chainId, + address, + }, + }); + + await initWalletData({ + account, chainId, - }, - }); + }); + + return account; + } catch (err: any) { + const toastr = new Toastr(); + toastr.error(err.message as unknown as string); + } finally { + dispatch(disableLoading()); + } }; return { From d26d8deccda259dae6ba08e7d7a6b9ed3efac6d6 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 17 Jan 2025 20:15:42 +0800 Subject: [PATCH 2/2] fix: after a account switched, the address is not reflected on the Snap home page UI (#487) * fix: use correct account on snap homepage * fix: eof --- .../starknet-snap/src/on-home-page.test.ts | 139 ++++-------------- packages/starknet-snap/src/on-home-page.ts | 50 ++----- 2 files changed, 40 insertions(+), 149 deletions(-) diff --git a/packages/starknet-snap/src/on-home-page.test.ts b/packages/starknet-snap/src/on-home-page.test.ts index dfbc9388..0079837e 100644 --- a/packages/starknet-snap/src/on-home-page.test.ts +++ b/packages/starknet-snap/src/on-home-page.test.ts @@ -1,79 +1,48 @@ import { ethers } from 'ethers'; -import { constants } from 'starknet'; -import { generateAccounts, type StarknetAccount } from './__tests__/helper'; import { HomePageController } from './on-home-page'; -import type { Network, SnapState } from './types/snapState'; +import { setupAccountController } from './rpcs/__tests__/helper'; +import type { Network } from './types/snapState'; import { BlockIdentifierEnum, ETHER_MAINNET, - STARKNET_SEPOLIA_TESTNET_NETWORK, + STARKNET_MAINNET_NETWORK, } from './utils/constants'; -import * as snapHelper from './utils/snap'; import * as starknetUtils from './utils/starknetUtils'; jest.mock('./utils/snap'); jest.mock('./utils/logger'); describe('homepageController', () => { - const state: SnapState = { - accContracts: [], - erc20Tokens: [], - networks: [STARKNET_SEPOLIA_TESTNET_NETWORK], - transactions: [], - currentNetwork: STARKNET_SEPOLIA_TESTNET_NETWORK, - }; - - const mockAccount = async (chainId: constants.StarknetChainId) => { - return (await generateAccounts(chainId, 1))[0]; - }; - - const mockState = async () => { - const getStateDataSpy = jest.spyOn(snapHelper, 'getStateData'); - getStateDataSpy.mockResolvedValue(state); - return { - getStateDataSpy, - }; - }; + const currentNetwork = STARKNET_MAINNET_NETWORK; class MockHomePageController extends HomePageController { - async getAddress(network: Network): Promise { - return super.getAddress(network); - } - async getBalance(network: Network, address: string): Promise { return super.getBalance(network, address); } } describe('execute', () => { - const prepareExecuteMock = (account: StarknetAccount, balance: string) => { - const getAddressSpy = jest.spyOn( - MockHomePageController.prototype, - 'getAddress', - ); + const setupExecuteTest = async (network: Network, balance = '1000') => { + const { account } = await setupAccountController({ network }); + const getBalanceSpy = jest.spyOn( MockHomePageController.prototype, 'getBalance', ); - getAddressSpy.mockResolvedValue(account.address); getBalanceSpy.mockResolvedValue(balance); + return { - getAddressSpy, + account, getBalanceSpy, }; }; it('returns the correct homepage response', async () => { - const { currentNetwork } = state; - await mockState(); - const account = await mockAccount( - currentNetwork?.chainId as unknown as constants.StarknetChainId, - ); const balance = '100'; - const { getAddressSpy, getBalanceSpy } = prepareExecuteMock( - account, + const { getBalanceSpy, account } = await setupExecuteTest( + currentNetwork, balance, ); @@ -119,7 +88,6 @@ describe('homepageController', () => { type: 'panel', }, }); - expect(getAddressSpy).toHaveBeenCalledWith(currentNetwork); expect(getBalanceSpy).toHaveBeenCalledWith( currentNetwork, account.address, @@ -127,12 +95,8 @@ describe('homepageController', () => { }); it('throws `Failed to initialize Snap HomePage` error if an error was thrown', async () => { - await mockState(); - const account = await mockAccount(constants.StarknetChainId.SN_SEPOLIA); - const balance = '100'; - - const { getAddressSpy } = prepareExecuteMock(account, balance); - getAddressSpy.mockReset().mockRejectedValue(new Error('error')); + const { getBalanceSpy } = await setupExecuteTest(currentNetwork); + getBalanceSpy.mockReset().mockRejectedValue(new Error('error')); const homepageController = new MockHomePageController(); await expect(homepageController.execute()).rejects.toThrow( @@ -141,84 +105,33 @@ describe('homepageController', () => { }); }); - describe('getAddress', () => { - const prepareGetAddressMock = async (account: StarknetAccount) => { - const getKeysFromAddressSpy = jest.spyOn( - starknetUtils, - 'getKeysFromAddressIndex', - ); - - getKeysFromAddressSpy.mockResolvedValue({ - privateKey: account.privateKey, - publicKey: account.publicKey, - addressIndex: account.addressIndex, - derivationPath: account.derivationPath as unknown as any, - }); - - const getCorrectContractAddressSpy = jest.spyOn( - starknetUtils, - 'getCorrectContractAddress', - ); - getCorrectContractAddressSpy.mockResolvedValue({ - address: account.address, - signerPubKey: account.publicKey, - upgradeRequired: false, - deployRequired: false, - }); - return { - getKeysFromAddressSpy, - getCorrectContractAddressSpy, - }; - }; - - it('returns the correct homepage response', async () => { - const network = STARKNET_SEPOLIA_TESTNET_NETWORK; - await mockState(); - const account = await mockAccount(constants.StarknetChainId.SN_SEPOLIA); - const { getKeysFromAddressSpy, getCorrectContractAddressSpy } = - await prepareGetAddressMock(account); - - const homepageController = new MockHomePageController(); - const result = await homepageController.getAddress(network); - - expect(result).toStrictEqual(account.address); - expect(getKeysFromAddressSpy).toHaveBeenCalledWith( - // BIP44 Deriver has mocked as undefined, hence this argument should be undefined - undefined, - network.chainId, - state, - 0, - ); - expect(getCorrectContractAddressSpy).toHaveBeenCalledWith( - network, - account.publicKey, - ); - }); - }); - describe('getBalance', () => { - const prepareGetBalanceMock = async (balance: number) => { + const setupGetBalanceTest = async (network: Network, balance: number) => { + const { account } = await setupAccountController({ network }); + const getBalanceSpy = jest.spyOn(starknetUtils, 'getBalance'); getBalanceSpy.mockResolvedValue(balance.toString(16)); return { + account, getBalanceSpy, }; }; it('returns the balance on pending block', async () => { - const network = STARKNET_SEPOLIA_TESTNET_NETWORK; const token = ETHER_MAINNET; const expectedBalance = 100; - await mockState(); - const { address } = await mockAccount( - constants.StarknetChainId.SN_SEPOLIA, + const { getBalanceSpy, account } = await setupGetBalanceTest( + currentNetwork, + expectedBalance, ); - const { getBalanceSpy } = await prepareGetBalanceMock(expectedBalance); const homepageController = new MockHomePageController(); - const result = await homepageController.getBalance(network, address); + const result = await homepageController.getBalance( + currentNetwork, + account.address, + ); expect(result).toStrictEqual( ethers.utils.formatUnits( @@ -227,9 +140,9 @@ describe('homepageController', () => { ), ); expect(getBalanceSpy).toHaveBeenCalledWith( - address, + account.address, token.address, - network, + currentNetwork, BlockIdentifierEnum.Pending, ); }); diff --git a/packages/starknet-snap/src/on-home-page.ts b/packages/starknet-snap/src/on-home-page.ts index 55c37c2f..39b1f7a1 100644 --- a/packages/starknet-snap/src/on-home-page.ts +++ b/packages/starknet-snap/src/on-home-page.ts @@ -10,21 +10,11 @@ import { import { ethers } from 'ethers'; import { NetworkStateManager } from './state/network-state-manager'; -import type { Network, SnapState } from './types/snapState'; -import { - getBip44Deriver, - getDappUrl, - getStateData, - logger, - toJson, -} from './utils'; +import type { Network } from './types/snapState'; +import { getDappUrl, logger, toJson } from './utils'; import { BlockIdentifierEnum, ETHER_MAINNET } from './utils/constants'; -import { - getBalance, - getCorrectContractAddress, - getKeysFromAddressIndex, -} from './utils/starknetUtils'; - +import { createAccountService } from './utils/factory'; +import { getBalance } from './utils/starknetUtils'; /** * The onHomePage handler to execute the home page event operation. */ @@ -37,20 +27,24 @@ export class HomePageController { /** * Execute the on home page event operation. - * It derives an account address with index 0 and retrieves the spendable balance of ETH. - * It returns a snap panel component with the address, network, and balance. + * It returns the component that contains the address, network, and balance for the current account. * - * @returns A promise that resolve to a OnHomePageResponse object. + * @returns A promise that resolve to a `OnHomePageResponse` object. */ async execute(): Promise { try { const network = await this.networkStateMgr.getCurrentNetwork(); - const address = await this.getAddress(network); + const accountService = createAccountService(network); - const balance = await this.getBalance(network, address); + const account = await accountService.getCurrentAccount(); - return this.buildComponents(address, network, balance); + const balance = await this.getBalance(network, account.address); + + // FIXME: The SNAP UI render method in buildComponents is deprecated, + // However, there is some tricky issue when using JSX components here, + // so we will keep using the deprecated method for now. + return this.buildComponents(account.address, network, balance); } catch (error) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions logger.error('Failed to execute onHomePage', toJson(error)); @@ -59,22 +53,6 @@ export class HomePageController { } } - protected async getAddress(network: Network): Promise { - const deriver = await getBip44Deriver(); - const state = await getStateData(); - - const { publicKey } = await getKeysFromAddressIndex( - deriver, - network.chainId, - state, - 0, - ); - - const { address } = await getCorrectContractAddress(network, publicKey); - - return address; - } - protected async getBalance( network: Network, address: string,