diff --git a/apps/browser-extension-wallet/src/hooks/useWalletManager.ts b/apps/browser-extension-wallet/src/hooks/useWalletManager.ts index 9f1565af3..9cd7d577e 100644 --- a/apps/browser-extension-wallet/src/hooks/useWalletManager.ts +++ b/apps/browser-extension-wallet/src/hooks/useWalletManager.ts @@ -24,6 +24,7 @@ import { AddWalletProps, AnyBip32Wallet, AnyWallet, + Bip32WalletAccount, WalletId, WalletManagerActivateProps, WalletManagerApi, @@ -87,9 +88,10 @@ type WalletManagerAddAccountProps = { type ActivateWalletProps = Omit; type CreateHardwareWalletRevampedParams = { - accountIndex: number; + accountIndexes: number[]; name: string; connection: Wallet.HardwareWalletConnection; + getAccountName?: (index: number) => string; }; type CreateHardwareWalletRevamped = (params: CreateHardwareWalletRevampedParams) => Promise; @@ -266,33 +268,37 @@ export const useWalletManager = (): UseWalletManager => { }, [currentChain]); const createHardwareWalletRevamped = useCallback( - async ({ accountIndex, connection, name }) => { - let extendedAccountPublicKey; - try { - extendedAccountPublicKey = await Wallet.getHwExtendedAccountPublicKey( - connection.type, + async ({ accountIndexes, connection, name, getAccountName = defaultAccountName }) => { + const accounts: Bip32WalletAccount[] = []; + for (const accountIndex of accountIndexes) { + let extendedAccountPublicKey; + try { + extendedAccountPublicKey = await Wallet.getHwExtendedAccountPublicKey( + connection.type, + accountIndex, + connection.type === WalletType.Ledger ? connection.value : undefined + ); + } catch (error: unknown) { + throw error; + } + accounts.push({ + extendedAccountPublicKey, accountIndex, - connection.type === WalletType.Ledger ? connection.value : undefined - ); - } catch (error: unknown) { - throw error; + metadata: { name: getAccountName(accountIndex) } + }); } + const addWalletProps: AddWalletProps = { - metadata: { name, lastActiveAccountIndex: accountIndex }, + metadata: { name, lastActiveAccountIndex: accountIndexes[0] }, type: connection.type, - accounts: [ - { - extendedAccountPublicKey, - accountIndex, - metadata: { name: defaultAccountName(accountIndex) } - } - ] + accounts }; + const walletId = await walletRepository.addWallet(addWalletProps); await walletManager.activate({ walletId, chainId: getCurrentChainId(), - accountIndex + accountIndex: accountIndexes[0] }); return { @@ -323,7 +329,7 @@ export const useWalletManager = (): UseWalletManager => { connectedDevice }: CreateHardwareWallet): Promise => createHardwareWalletRevamped({ - accountIndex, + accountIndexes: [accountIndex], connection: { type: connectedDevice, value: typeof deviceConnection !== 'boolean' ? deviceConnection : undefined diff --git a/apps/browser-extension-wallet/src/lib/scripts/background/config.ts b/apps/browser-extension-wallet/src/lib/scripts/background/config.ts index 6b05c1885..61671a73f 100644 --- a/apps/browser-extension-wallet/src/lib/scripts/background/config.ts +++ b/apps/browser-extension-wallet/src/lib/scripts/background/config.ts @@ -13,6 +13,7 @@ export const backgroundServiceProperties: RemoteApiProperties tokenPrices$: RemoteApiPropertyType.HotObservable }, handleOpenBrowser: RemoteApiPropertyType.MethodReturningPromise, + handleOpenNamiBrowser: RemoteApiPropertyType.MethodReturningPromise, handleOpenPopup: RemoteApiPropertyType.MethodReturningPromise, handleChangeTheme: RemoteApiPropertyType.MethodReturningPromise, handleChangeMode: RemoteApiPropertyType.MethodReturningPromise, diff --git a/apps/browser-extension-wallet/src/lib/scripts/background/services/utilityServices.ts b/apps/browser-extension-wallet/src/lib/scripts/background/services/utilityServices.ts index 706276a41..550cb787c 100644 --- a/apps/browser-extension-wallet/src/lib/scripts/background/services/utilityServices.ts +++ b/apps/browser-extension-wallet/src/lib/scripts/background/services/utilityServices.ts @@ -7,6 +7,7 @@ import { Message, MessageTypes, OpenBrowserData, + OpenNamiBrowserData, MigrationState, TokenPrices, CoinPrices, @@ -87,11 +88,18 @@ const handleOpenBrowser = async (data: OpenBrowserData) => { case BrowserViewSections.NAMI_MIGRATION: path = walletRoutePaths.namiMigration.root; break; + case BrowserViewSections.NAMI_HW_FLOW: + path = walletRoutePaths.namiMigration.hwFlow; + break; } const params = data.urlSearchParams ? `?${data.urlSearchParams}` : ''; await tabs.create({ url: `app.html#${path}${params}` }).catch((error) => console.error(error)); }; +const handleOpenNamiBrowser = async (data: OpenNamiBrowserData) => { + await tabs.create({ url: `popup.html#${data.path}` }).catch((error) => console.error(error)); +}; + const handleOpenPopup = async () => { if (typeof chrome.action.openPopup !== 'function') return; await closeAllLaceWindows(); @@ -184,6 +192,7 @@ exposeApi( { api$: of({ handleOpenBrowser, + handleOpenNamiBrowser, handleOpenPopup, requestMessage$, migrationState$, diff --git a/apps/browser-extension-wallet/src/lib/scripts/types/background-service.ts b/apps/browser-extension-wallet/src/lib/scripts/types/background-service.ts index 89dd9a63e..bd507ad10 100644 --- a/apps/browser-extension-wallet/src/lib/scripts/types/background-service.ts +++ b/apps/browser-extension-wallet/src/lib/scripts/types/background-service.ts @@ -43,7 +43,8 @@ export enum BrowserViewSections { FORGOT_PASSWORD = 'forgot_password', NEW_WALLET = 'new_wallet', ADD_SHARED_WALLET = 'add_shared_wallet', - NAMI_MIGRATION = 'nami_migration' + NAMI_MIGRATION = 'nami_migration', + NAMI_HW_FLOW = 'nami_hw_flow' } export interface OpenBrowserData { @@ -51,6 +52,10 @@ export interface OpenBrowserData { urlSearchParams?: string; } +export interface OpenNamiBrowserData { + path: string; +} + interface ChangeThemeMessage { type: MessageTypes.CHANGE_THEME; data: ChangeThemeData; @@ -73,6 +78,7 @@ export type Message = ChangeThemeMessage | HTTPConnectionMessage | OpenBrowserMe export type BackgroundService = { handleOpenBrowser: (data: OpenBrowserData, urlSearchParams?: string) => Promise; handleOpenPopup: () => Promise; + handleOpenNamiBrowser: (data: OpenNamiBrowserData) => Promise; requestMessage$: Subject; migrationState$: BehaviorSubject; coinPrices: CoinPrices; diff --git a/apps/browser-extension-wallet/src/popup.tsx b/apps/browser-extension-wallet/src/popup.tsx index a421f0ef2..952b28e92 100644 --- a/apps/browser-extension-wallet/src/popup.tsx +++ b/apps/browser-extension-wallet/src/popup.tsx @@ -29,8 +29,8 @@ import { storage } from 'webextension-polyfill'; const App = (): React.ReactElement => { const [mode, setMode] = useState<'lace' | 'nami'>(); storage.onChanged.addListener((changes) => { - const oldModeValue = changes.BACKGROUND_STORAGE.oldValue?.namiMigration; - const newModeValue = changes.BACKGROUND_STORAGE.newValue?.namiMigration; + const oldModeValue = changes.BACKGROUND_STORAGE?.oldValue?.namiMigration; + const newModeValue = changes.BACKGROUND_STORAGE?.newValue?.namiMigration; if (oldModeValue?.mode !== newModeValue?.mode) { setMode(newModeValue); // Force back to original routing diff --git a/apps/browser-extension-wallet/src/routes/wallet-paths.ts b/apps/browser-extension-wallet/src/routes/wallet-paths.ts index dae36fb23..fe436a28b 100644 --- a/apps/browser-extension-wallet/src/routes/wallet-paths.ts +++ b/apps/browser-extension-wallet/src/routes/wallet-paths.ts @@ -39,7 +39,8 @@ export const walletRoutePaths = { activating: '/nami/migration/activating', welcome: '/nami/migration/welcome', customize: '/nami/migration/customize', - allDone: '/nami/migration/all-done' + allDone: '/nami/migration/all-done', + hwFlow: '/nami/nami-mode/hwTab' } }; diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/multi-wallet/hardware-wallet/context.tsx b/apps/browser-extension-wallet/src/views/browser-view/features/multi-wallet/hardware-wallet/context.tsx index ec9ac7f7d..e60df2aae 100644 --- a/apps/browser-extension-wallet/src/views/browser-view/features/multi-wallet/hardware-wallet/context.tsx +++ b/apps/browser-extension-wallet/src/views/browser-view/features/multi-wallet/hardware-wallet/context.tsx @@ -170,7 +170,8 @@ export const HardwareWalletProvider = ({ children }: HardwareWalletProviderProps try { cardanoWallet = await createHardwareWalletRevamped({ connection, - ...walletData + ...walletData, + accountIndexes: [walletData.accountIndex] }); } catch (error) { console.error('ERROR creating hardware wallet', { error }); diff --git a/apps/browser-extension-wallet/src/views/nami-mode/NamiView.tsx b/apps/browser-extension-wallet/src/views/nami-mode/NamiView.tsx index 1d918245f..40bef8397 100644 --- a/apps/browser-extension-wallet/src/views/nami-mode/NamiView.tsx +++ b/apps/browser-extension-wallet/src/views/nami-mode/NamiView.tsx @@ -32,6 +32,7 @@ import { BackgroundStorage } from '@lib/scripts/types'; import { isKeyHashAddress } from '@cardano-sdk/wallet'; import { useWalletState } from '@hooks/useWalletState'; import { certificateInspectorFactory } from '@src/features/dapp/components/confirm-transaction/utils'; +import { useWrapWithTimeout } from '../browser-view/features/multi-wallet/hardware-wallet/useWrapWithTimeout'; const { AVAILABLE_CHAINS, DEFAULT_SUBMIT_API } = config(); @@ -40,11 +41,22 @@ export const NamiView = withDappContext((): React.ReactElement => { const { priceResult } = useFetchCoinPrice(); const [namiMigration, setNamiMigration] = useState(); const backgroundServices = useBackgroundServiceAPIContext(); - const { createWallet, getMnemonic, deleteWallet, switchNetwork, enableCustomNode, addAccount, walletRepository } = - useWalletManager(); + const { + createWallet, + getMnemonic, + deleteWallet, + switchNetwork, + enableCustomNode, + addAccount, + walletRepository, + connectHardwareWalletRevamped, + createHardwareWalletRevamped, + saveHardwareWallet + } = useWalletManager(); const { walletUI, inMemoryWallet, + walletType, walletInfo, currentChain, environmentName, @@ -128,6 +140,14 @@ export const NamiView = withDappContext((): React.ReactElement => { [walletState] ); + const openHWFlow = useCallback( + (path: string) => { + backgroundServices.handleOpenNamiBrowser({ path }); + }, + [backgroundServices] + ); + const connectHW = useWrapWithTimeout(connectHardwareWalletRevamped); + return ( { transactions: sortedHistoryTx, eraSummaries: walletState?.eraSummaries, getTxInputsValueAndAddress, - certificateInspectorFactory + certificateInspectorFactory, + openHWFlow, + walletType, + connectHW, + createHardwareWalletRevamped, + saveHardwareWallet }} > diff --git a/apps/browser-extension-wallet/src/views/nami-mode/index.scss b/apps/browser-extension-wallet/src/views/nami-mode/index.scss index 2ebc045cb..ab9e53330 100644 --- a/apps/browser-extension-wallet/src/views/nami-mode/index.scss +++ b/apps/browser-extension-wallet/src/views/nami-mode/index.scss @@ -1,5 +1,5 @@ #nami-mode { - width: 100%; + width: 100vw; height: 100%; font-family: sans-serif; } diff --git a/packages/nami/.storybook/mocks/cardano-sdk.mock.ts b/packages/nami/.storybook/mocks/cardano-sdk.mock.ts index 361062bf5..3780debe5 100644 --- a/packages/nami/.storybook/mocks/cardano-sdk.mock.ts +++ b/packages/nami/.storybook/mocks/cardano-sdk.mock.ts @@ -8,6 +8,13 @@ export const Cardano = { NetworkMagics: {}, }; +export const WalletType = { + InMemory: "InMemory", + Ledger: "Ledger", + Trezor: "Trezor", + Script: "Script" +} + export const Serialization = { TransactionOutput: function () {}, Value: function () { diff --git a/packages/nami/src/adapters/account.test.ts b/packages/nami/src/adapters/account.test.ts index 1420bd973..3cc399806 100644 --- a/packages/nami/src/adapters/account.test.ts +++ b/packages/nami/src/adapters/account.test.ts @@ -1,7 +1,7 @@ import { act, renderHook } from '@testing-library/react-hooks'; import { BehaviorSubject, of } from 'rxjs'; -import { useAccount } from './account'; +import { getNextAccountIndex, useAccount } from './account'; import type { AnyWallet, @@ -68,7 +68,7 @@ const getAccountData = ( index: accIndex, walletId: wallet.walletId, name: acc?.metadata?.name || `${wallet.type} ${accIndex}`, - hw: wallet.type === 'Ledger' || wallet.type === 'Trezor', + type: wallet.type, ...acc?.metadata?.namiMode, }; }; @@ -263,7 +263,9 @@ describe('useAccount', () => { }), ); - expect(result.current.nextIndex).toEqual(3); + expect(getNextAccountIndex(result.current.allAccounts, 'wallet1')).toEqual( + 3, + ); act(() => { wallets$.next([ @@ -296,7 +298,9 @@ describe('useAccount', () => { ]); }); - expect(result.current.nextIndex).toEqual(2); + expect(getNextAccountIndex(result.current.allAccounts, 'wallet1')).toEqual( + 2, + ); }); it('should call removeWallet with correct arguments', async () => { diff --git a/packages/nami/src/adapters/account.ts b/packages/nami/src/adapters/account.ts index c6f1ad016..d02f1f29e 100644 --- a/packages/nami/src/adapters/account.ts +++ b/packages/nami/src/adapters/account.ts @@ -47,16 +47,20 @@ export interface Account { avatar?: string; balance?: string; recentSendToAddress?: string; - hw?: boolean; + type?: WalletType; } export interface UseAccount { allAccounts: Account[]; activeAccount: Account; nonActiveAccounts: Account[]; - nextIndex: number; addAccount: ( - props: Readonly<{ index: number; name: string; passphrase: Uint8Array }>, + props: Readonly<{ + index: number; + name: string; + passphrase: Uint8Array; + walletId: string; + }>, ) => Promise; activateAccount: ( props: Readonly<{ @@ -97,13 +101,11 @@ const getActiveAccountMetadata = ({ ); }; -const getNextAccountIndex = ( +export const getNextAccountIndex = ( accounts: readonly Account[], - activeAccount: Readonly, + walletId: string, ) => { - const walletAccounts = accounts.filter( - a => a.walletId === activeAccount.walletId, - ); + const walletAccounts = accounts.filter(a => a.walletId === walletId); for (const [index, account] of walletAccounts.entries()) { if (account.index !== index) { @@ -125,7 +127,7 @@ const getAcountsMapper = index: accountIndex, walletId: wallet.walletId, name: metadata?.name || `${wallet.type} ${accountIndex}`, - hw: wallet.type === WalletType.Ledger || wallet.type === WalletType.Trezor, + type: wallet.type, ...metadata.namiMode, }); @@ -186,10 +188,6 @@ export const useAccount = ({ return { allAccounts: allAccountsSorted, activeAccount, - nextIndex: useMemo( - () => getNextAccountIndex(allAccountsSorted, activeAccount), - [allAccountsSorted, activeAccount], - ), nonActiveAccounts: useMemo( () => allAccountsSorted.filter( @@ -199,7 +197,7 @@ export const useAccount = ({ [allAccountsSorted, accountIndex, walletId], ), addAccount: useCallback( - async ({ index, name, passphrase }) => { + async ({ index, name, passphrase, walletId }) => { const wallet = wallets?.find(elm => elm.walletId === walletId); if (wallet === undefined || wallet.type === WalletType.Script) { return; diff --git a/packages/nami/src/features/outside-handles-provider/types.ts b/packages/nami/src/features/outside-handles-provider/types.ts index 3dd8bfaa5..3989fe9ca 100644 --- a/packages/nami/src/features/outside-handles-provider/types.ts +++ b/packages/nami/src/features/outside-handles-provider/types.ts @@ -6,6 +6,7 @@ import type { WalletManagerActivateProps, WalletManagerApi, WalletRepositoryApi, + WalletType, } from '@cardano-sdk/web-extension'; import type { Wallet } from '@lace/cardano'; import type { PasswordObj as Password } from '@lace/core'; @@ -104,4 +105,19 @@ export interface OutsideHandlesContextValue { certificateInspectorFactory: ( type: Wallet.Cardano.CertificateType, ) => (tx: Readonly) => Promise; + openHWFlow: (path: string) => void; + walletType: WalletType; + connectHW: (usbDevice: USBDevice) => Promise; + createHardwareWalletRevamped: ( + params: Readonly<{ + accountIndexes: number[]; + name: string; + connection: Wallet.HardwareWalletConnection; + getAccountName?: (index: number) => string; + }>, + ) => Promise; + saveHardwareWallet: ( + wallet: Readonly, + chainName?: Wallet.ChainName, + ) => Promise; } diff --git a/packages/nami/src/ui/UpgradeToLaceHeader.tsx b/packages/nami/src/ui/UpgradeToLaceHeader.tsx index 676cd13f0..d34d95ca3 100644 --- a/packages/nami/src/ui/UpgradeToLaceHeader.tsx +++ b/packages/nami/src/ui/UpgradeToLaceHeader.tsx @@ -1,6 +1,8 @@ +/* eslint-disable unicorn/no-null */ import React from 'react'; import { motion } from 'framer-motion'; +import { useLocation } from 'react-router-dom'; import { SwitchToLaceBanner } from './app/components/switchToLaceBanner'; @@ -9,6 +11,10 @@ export const UpgradeToLaceHeader = ({ }: { switchWalletMode: () => Promise; }) => { + const location = useLocation(); + + if (location.pathname.startsWith('/hwTab')) return null; + return ( ) : ( - ... + Select to load... )} {isActive && ( diff --git a/packages/nami/src/ui/app/hw/connect-hw.tsx b/packages/nami/src/ui/app/hw/connect-hw.tsx index a067bca7e..804afd2cf 100644 --- a/packages/nami/src/ui/app/hw/connect-hw.tsx +++ b/packages/nami/src/ui/app/hw/connect-hw.tsx @@ -1,3 +1,4 @@ +/* eslint-disable unicorn/no-null */ /* eslint-disable @typescript-eslint/no-misused-promises, @typescript-eslint/no-unsafe-assignment */ import type { ReactElement } from 'react'; import React from 'react'; @@ -6,17 +7,17 @@ import { ChevronRightIcon } from '@chakra-ui/icons'; import { Box, Button, Icon, Image, Text, useColorMode } from '@chakra-ui/react'; import { MdUsb } from 'react-icons/md'; -import { initHW } from '../../../api/extension'; import LedgerLogo from '../../../assets/img/ledgerLogo.svg'; import TrezorLogo from '../../../assets/img/trezorLogo.svg'; import { HW } from '../../../config/config'; import { Events } from '../../../features/analytics/events'; import { useCaptureEvent } from '../../../features/analytics/hooks'; +import { useOutsideHandles } from '../../../features/outside-handles-provider/useOutsideHandles'; -import type { HardwareDeviceInfo } from './types'; +import type { Wallet } from '@lace/cardano'; interface ConnectHWProps { - onConfirm: (data: Readonly) => void; + onConfirm: (data: Readonly) => void; } const MANUFACTURER: Record = { @@ -26,6 +27,7 @@ const MANUFACTURER: Record = { export const ConnectHW = ({ onConfirm }: ConnectHWProps): ReactElement => { const capture = useCaptureEvent(); + const { connectHW } = useOutsideHandles(); const { colorMode } = useColorMode(); const [selected, setSelected] = React.useState(''); const [isLoading, setIsLoading] = React.useState(false); @@ -35,6 +37,7 @@ export const ConnectHW = ({ onConfirm }: ConnectHWProps): ReactElement => { setIsLoading(true); setError(''); try { + let connectionResult: Wallet.HardwareWalletConnection | null = null; const device = await navigator.usb.requestDevice({ filters: [], }); @@ -45,19 +48,20 @@ export const ConnectHW = ({ onConfirm }: ConnectHWProps): ReactElement => { setIsLoading(false); return; } - if (selected === HW.ledger) { - try { - await initHW({ device: selected, id: device.productId }); - } catch { - setError('Cardano app not opened'); - setIsLoading(false); - return; - } + + try { + connectionResult = await connectHW(device); + } catch { + setError('Cardano app not opened'); + setIsLoading(false); + return; } - void capture(Events.HWConnectNextClick); - onConfirm({ device: selected, id: device.productId }); - return; + if (!!connectionResult) { + void capture(Events.HWConnectNextClick); + onConfirm(connectionResult); + return; + } } catch { setError('Device not found'); } diff --git a/packages/nami/src/ui/app/hw/hw.stories.tsx b/packages/nami/src/ui/app/hw/hw.stories.tsx index 42d3b3a1a..fb268ed76 100644 --- a/packages/nami/src/ui/app/hw/hw.stories.tsx +++ b/packages/nami/src/ui/app/hw/hw.stories.tsx @@ -8,19 +8,34 @@ import { fn, userEvent, within } from '@storybook/test'; import { createHWAccounts, getHwAccounts, - initHW, } from '../../../api/extension/api.mock'; -import { accountHW } from '../../../mocks/account.mock'; +import { useOutsideHandles } from '../../../features/outside-handles-provider/useOutsideHandles.mock'; import { HWConnectFlow } from './hw'; +import { SuccessAndClose } from './success-and-close'; const HWConnectStory = ({ colorMode, -}: Readonly<{ colorMode: 'dark' | 'light' }>): React.ReactElement => { + Component, +}: Readonly<{ + colorMode: 'dark' | 'light'; + Component: React.FC<{ colorMode: 'dark' | 'light' }>; +}>): React.ReactElement => { const { setColorMode } = useColorMode(); setColorMode(colorMode); - return ; + return Component ? : ; +}; + +const HWSuccessStory = ({ + colorMode, +}: Readonly<{ + colorMode: 'dark' | 'light'; +}>): React.ReactElement => { + const { setColorMode } = useColorMode(); + setColorMode(colorMode); + + return ; }; declare global { @@ -61,12 +76,6 @@ const meta: Meta = { }, }, beforeEach: () => { - getHwAccounts.mockImplementation(async () => { - return await Promise.resolve([accountHW]); - }); - createHWAccounts.mockImplementation(async () => { - return await Promise.resolve([accountHW]); - }); window.chrome = { runtime: { getURL: (): string => { @@ -94,9 +103,16 @@ const meta: Meta = { }; }); + useOutsideHandles.mockImplementation(() => { + return { + connectHW: () => true, + }; + }); + return () => { getHwAccounts.mockClear(); createHWAccounts.mockClear(); + useOutsideHandles.mockClear(); }; }, }; @@ -162,43 +178,8 @@ export const SelectAccountDark: Story = { }; export const SuccessAndCloseLight: Story = { - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement); - - await step('Select account', async () => { - const ledgerButton = await canvas.findByTestId('ledger'); - await userEvent.click(ledgerButton); - - const continueButton = await canvas.findByText('Continue'); - await userEvent.click(continueButton); - }); - - await step('Submit and close', async () => { - const connectButton = await canvas.findByText('Continue'); - await userEvent.click(connectButton); - }); - }, - beforeEach: () => { - initHW.mockImplementation(async () => { - return await Promise.resolve({ - getExtendedPublicKeys: () => { - return [ - { - publicKeyHex: - '4335d502b9888f7c862174328c0fd1eb037931cb5cf46a6d3e307fac3f37d619', - chainCodeHex: - '07080cdbb22a73d911705592afdd82b1a51ce62e3f33d68499b3ffcff28cce3e', - }, - ]; - }, - }); - }); - - return () => { - initHW.mockReset(); - }; - }, parameters: { + Component: HWSuccessStory, colorMode: 'light', }, }; @@ -206,6 +187,7 @@ export const SuccessAndCloseLight: Story = { export const SuccessAndCloseDark: Story = { ...SuccessAndCloseLight, parameters: { + Component: HWSuccessStory, colorMode: 'dark', }, }; diff --git a/packages/nami/src/ui/app/hw/hw.tsx b/packages/nami/src/ui/app/hw/hw.tsx index 651ebdf10..fef38de8e 100644 --- a/packages/nami/src/ui/app/hw/hw.tsx +++ b/packages/nami/src/ui/app/hw/hw.tsx @@ -1,24 +1,33 @@ +/* eslint-disable unicorn/no-null */ /* eslint-disable functional/immutable-data, @typescript-eslint/no-unsafe-assignment */ import type { ReactElement } from 'react'; -import React, { useRef, useState } from 'react'; +import React, { useState } from 'react'; import { Box, Image, useColorModeValue } from '@chakra-ui/react'; +import { useHistory } from 'react-router-dom'; import LogoOriginal from '../../../assets/img/logo.svg'; import LogoWhite from '../../../assets/img/logoWhite.svg'; import { ConnectHW } from './connect-hw'; import { SelectAccounts } from './select-account'; -import { SuccessAndClose } from './success-and-close'; -import type { HardwareDeviceInfo } from './types'; +import type { UseAccount } from '../../../adapters/account'; +import type { Wallet } from '@lace/cardano'; -export const HWConnectFlow = (): ReactElement => { +export const HWConnectFlow = ({ + accounts, + activateAccount, +}: Readonly<{ + accounts: UseAccount['allAccounts']; + activateAccount: UseAccount['activateAccount']; +}>): ReactElement => { + const history = useHistory(); const Logo = useColorModeValue(LogoOriginal, LogoWhite); const cardColor = useColorModeValue('white', 'gray.900'); const backgroundColor = useColorModeValue('gray.200', 'gray.800'); - const [tab, setTab] = useState(0); - const data = useRef({ device: '', id: 0 }); + const [connection, setConnection] = + useState(null); return ( { background={cardColor} fontSize="sm" > - {tab === 0 && ( + {!connection && ( { - data.current = { device, id }; - setTab(1); + onConfirm={(data): void => { + setConnection(data); }} /> )} - {tab === 1 && ( + {connection && ( { - setTab(2); + history.push('/hwTab/success'); }} /> )} - {tab === 2 && } ); diff --git a/packages/nami/src/ui/app/hw/select-account.tsx b/packages/nami/src/ui/app/hw/select-account.tsx index 2b95be9ba..a7ffc46f0 100644 --- a/packages/nami/src/ui/app/hw/select-account.tsx +++ b/packages/nami/src/ui/app/hw/select-account.tsx @@ -1,130 +1,125 @@ -/* eslint-disable @typescript-eslint/no-misused-promises, unicorn/no-null, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/strict-boolean-expressions */ -/* eslint-disable @typescript-eslint/no-unsafe-call, functional/immutable-data, @typescript-eslint/no-unsafe-assignment */ - +/* eslint-disable @typescript-eslint/no-floating-promises */ import type { ReactElement } from 'react'; -import React from 'react'; +import React, { useCallback, useMemo } from 'react'; -import { HARDENED } from '@cardano-foundation/ledgerjs-hw-app-cardano'; +import { WalletType } from '@cardano-sdk/web-extension'; import { ChevronRightIcon } from '@chakra-ui/icons'; import { Box, Button, Checkbox, Text } from '@chakra-ui/react'; -import TrezorConnect from '@trezor/connect-web'; +import { Wallet } from '@lace/cardano'; -import { - getHwAccounts, - indexToHw, - createHWAccounts, - initHW, -} from '../../../api/extension'; -import { HW } from '../../../config/config'; import { Events } from '../../../features/analytics/events'; import { useCaptureEvent } from '../../../features/analytics/hooks'; +import { useOutsideHandles } from '../../../features/outside-handles-provider/useOutsideHandles'; import { Scrollbars } from '../components/scrollbar'; import TrezorWidget from '../components/TrezorWidget'; -import type { HardwareDeviceInfo } from './types'; +import type { UseAccount } from '../../../adapters/account'; + +const accountsIndexes = Object.keys(Array.from({ length: 50 })); +const getAccountName = ( + index: number, + type: WalletType.Ledger | WalletType.Trezor, +) => `${type} ${index + 1}`; interface SelectAccountsProps { - data: HardwareDeviceInfo; + connection: Wallet.HardwareWalletConnection; onConfirm: () => void; + accounts: UseAccount['allAccounts']; + activateAccount: UseAccount['activateAccount']; } export const SelectAccounts = ({ - data, + connection, onConfirm, + accounts, + activateAccount, }: Readonly): ReactElement | null => { const capture = useCaptureEvent(); + const { + createHardwareWalletRevamped, + saveHardwareWallet, + environmentName, + walletRepository, + } = useOutsideHandles(); + + const existingAccountsIndexes = useMemo( + () => + new Set( + accounts?.filter(a => a.type === connection.type).map(a => a.index), + ), + [accounts, connection], + ); + const walletId = useMemo( + () => accounts?.find(a => a.type === connection.type)?.walletId, + [accounts], + ); + const [selected, setSelected] = React.useState({ 0: true }); const [error, setError] = React.useState(''); const trezorReference = React.useRef(); - const [existing, setExisting] = React.useState({}); const [isLoading, setIsLoading] = React.useState(false); - const [isInit, setIsInit] = React.useState(false); - - React.useEffect(() => { - const getExistingAccounts = async (): Promise => { - const hwAccounts = await getHwAccounts({ - device: data.device, - id: data.id, - }); - - const existing = {}; - // eslint-disable-next-line functional/no-loop-statements - for (const accountIndex of Object.keys(hwAccounts)) - existing[indexToHw(accountIndex).account] = true; - setExisting(existing); - setIsInit(true); - }; - - getExistingAccounts().catch(error => { - console.error('Error getting existing accounts:', error); - }); - }, []); const handleSelectAccount = async (): Promise => { setIsLoading(true); setError(''); - const accountIndexes = Object.keys(selected).filter( - currentState => selected[currentState] && !existing[currentState], - ); - try { - const { device, id } = data; - // eslint-disable-next-line functional/no-let - let accounts; - if (device === HW.ledger) { - const appAda = await initHW({ device, id }); - const ledgerKeys = await appAda.getExtendedPublicKeys({ - paths: accountIndexes.map(index => [ - HARDENED + 1852, - HARDENED + 1815, - HARDENED + Number.parseInt(index), - ]), - }); - accounts = ledgerKeys.map( - ( - { - publicKeyHex, - chainCodeHex, - }: Readonly<{ publicKeyHex: string; chainCodeHex: string }>, - index: number, - ) => ({ - accountIndex: `${HW.ledger}-${id}-${accountIndexes[index]}`, - publicKey: publicKeyHex + chainCodeHex, - name: `Ledger ${Number.parseInt(accountIndexes[index]) + 1}`, - }), - ); - } else if (device == HW.trezor) { - await initHW({ device, id }); - const trezorKeys = await TrezorConnect.cardanoGetPublicKey({ - bundle: accountIndexes.map(index => ({ - path: `m/1852'/1815'/${Number.parseInt(index)}'`, - showOnTrezor: false, - })), - }); - if (!trezorKeys.success) { - trezorReference.current.closeModal(); + const accountIndexes = Object.keys(selected) + .filter( + currentState => + selected[currentState] && !isAccountDisabled(currentState), + ) + .map(Number); + // enable more accounts for existing hw + if (walletId && existingAccountsIndexes.size > 0) { + for (const accountIndex of accountIndexes) { + try { + await walletRepository.addAccount({ + accountIndex, + extendedAccountPublicKey: + await Wallet.getHwExtendedAccountPublicKey( + connection.type, + accountIndex, + connection.type === WalletType.Ledger + ? connection.value + : undefined, + ), + metadata: { + name: getAccountName(accountIndex, connection.type), + namiMode: { avatar: Math.random().toString() }, + }, + walletId, + }); + } catch (error: unknown) { + throw error; } - accounts = trezorKeys.payload.map(({ publicKey }, index) => ({ - accountIndex: `${HW.trezor}-${id}-${accountIndexes[index]}`, - publicKey, - name: `Trezor ${Number.parseInt(accountIndexes[index]) + 1}`, - })); - trezorReference.current.closeModal(); } - await createHWAccounts(accounts); - void capture(Events.HWSelectAccountNextClick, { - numAccounts: accountIndexes.length, - }); - onConfirm(); - return; - } catch (error_) { - console.log(error_); - setError('An error occured'); + await activateAccount({ accountIndex: accountIndexes[0], walletId }); + } else { + // create new hw + let cardanoWallet: Wallet.CardanoWallet; + try { + cardanoWallet = await createHardwareWalletRevamped({ + connection, + name: 'Wallet 1', + accountIndexes, + getAccountName: (index: number) => `${connection.type} ${index + 1}`, + }); + await saveHardwareWallet(cardanoWallet, environmentName); + } catch (error_) { + console.log(error_); + setError('An error occured'); + } } - + onConfirm(); + void capture(Events.HWSelectAccountNextClick, { + numAccounts: accountIndexes.length, + }); setIsLoading(false); }; - if (!isInit) return null; + const isAccountDisabled = useCallback( + (index: string) => existingAccountsIndexes.has(Number(index)), + [existingAccountsIndexes], + ); return ( <> @@ -146,10 +141,10 @@ export const SelectAccounts = ({ }} autoHide > - {Object.keys(Array.from({ length: 50 })).map(accountIndex => ( + {accountsIndexes.map(accountIndex => ( {' '} Account {Number.parseInt(accountIndex) + 1}{' '} - {accountIndex == 0 && ' - Default'} + {accountIndex === '0' && ' - Default'} >, ): void => { @@ -181,13 +178,16 @@ export const SelectAccounts = ({ isDisabled={ isLoading || Object.keys(selected).filter( - currentState => selected[currentState] && !existing[currentState], + currentState => + selected[currentState] && !isAccountDisabled(currentState), ).length <= 0 } isLoading={isLoading} mt="auto" rightIcon={} - onClick={handleSelectAccount} + onClick={() => { + void handleSelectAccount(); + }} > Continue diff --git a/packages/nami/src/ui/app/hw/success-and-close.tsx b/packages/nami/src/ui/app/hw/success-and-close.tsx index 930f98a82..b2ee0442f 100644 --- a/packages/nami/src/ui/app/hw/success-and-close.tsx +++ b/packages/nami/src/ui/app/hw/success-and-close.tsx @@ -1,41 +1,76 @@ /*eslint-disable @typescript-eslint/no-misused-promises */ -import type { ReactElement } from 'react'; import React from 'react'; +import type { ReactElement } from 'react'; -import { Box, Button, Text } from '@chakra-ui/react'; +import { Box, Image, Button, Text, useColorModeValue } from '@chakra-ui/react'; import { Planet } from 'react-kawaii'; +import LogoOriginal from '../../../assets/img/logo.svg'; +import LogoWhite from '../../../assets/img/logoWhite.svg'; import { Events } from '../../../features/analytics/events'; import { useCaptureEvent } from '../../../features/analytics/hooks'; export const SuccessAndClose = (): ReactElement => { const capture = useCaptureEvent(); + const cardColor = useColorModeValue('white', 'gray.900'); + const backgroundColor = useColorModeValue('gray.200', 'gray.800'); + const Logo = useColorModeValue(LogoOriginal, LogoWhite); + return ( - <> - - Successfully added accounts! - - - - - - You can now close this tab and continue with the extension. - - - + + Successfully added accounts! + + + + + + You can now close this tab and continue with the extension. + + + + ); }; diff --git a/packages/nami/src/ui/app/pages/send.tsx b/packages/nami/src/ui/app/pages/send.tsx index 2ccaad4f1..8d5134a82 100644 --- a/packages/nami/src/ui/app/pages/send.tsx +++ b/packages/nami/src/ui/app/pages/send.tsx @@ -423,7 +423,7 @@ const Send = ({ return ( <> & { +export type Props = Pick< + OutsideHandlesContextValue, + 'cardanoCoin' | 'openHWFlow' +> & { activeAddress: string; removeAccount: UseAccount['removeAccount']; activateAccount: UseAccount['activateAccount']; addAccount: UseAccount['addAccount']; - nextIndex: number; currency: CurrencyCode; activeAccount: UseAccount['activeAccount']; accounts: UseAccount['allAccounts']; @@ -109,7 +114,6 @@ export type Props = Pick & { const Wallet = ({ activeAddress, - nextIndex, currency, activeAccount, accounts, @@ -124,6 +128,7 @@ const Wallet = ({ assets, nfts, setAvatar, + openHWFlow, }: Readonly) => { const capture = useCaptureEvent(); const history = useHistory(); @@ -138,10 +143,16 @@ const Wallet = ({ const canDeleteAccount = useMemo( () => - !!activeAccount.hw || + activeAccount.type === WalletType.Ledger || + activeAccount.type === WalletType.Trezor || accounts.filter(a => a.walletId === activeAccount.walletId).length > 1, [accounts, activeAccount], ); + + const inMemoryWallet = useMemo( + () => accounts.find(a => a.type === WalletType.InMemory), + [accounts], + ); const onAccountClick = useCallback( async (account: Readonly) => { if ( @@ -162,7 +173,7 @@ const Wallet = ({ <> await onAccountClick(account)} + onClick={() => { + void onAccountClick(account); + }} avatar={account.avatar} name={account.name} balance={account.balance} @@ -254,22 +267,27 @@ const Wallet = ({ account.walletId === activeAccount.walletId } cardanoCoin={cardanoCoin} - isHW={account.hw} + isHW={ + account.type === WalletType.Ledger || + account.type === WalletType.Trezor + } /> ))} - } - onClick={() => { - void capture(Events.SettingsNewAccountClick); - newAccountRef.current.openModal(); - }} - > - New Account - + {!!inMemoryWallet && ( + } + onClick={() => { + void capture(Events.SettingsNewAccountClick); + newAccountRef.current.openModal(); + }} + > + New Account + + )} {canDeleteAccount && ( } - onClick={() => { - capture(Events.HWConnectClick); - createTab(TAB.hw); + onClick={(): void => { + void (async () => { + await capture(Events.HWConnectClick); + openHWFlow(TAB.hw); + })(); }} > Connect Hardware Wallet @@ -568,11 +588,14 @@ const Wallet = ({ - + {inMemoryWallet?.walletId && ( + + )} (({ nextIndex, addAccount }, ref) => { +>(({ accounts, addAccount, walletId }, ref) => { const capture = useCaptureEvent(); const { isOpen, onOpen, onClose } = useDisclosure(); const [isLoading, setIsLoading] = React.useState(false); @@ -606,11 +630,12 @@ const NewAccountModal = React.forwardRef< setIsLoading(true); try { await addAccount({ - index: nextIndex, + index: getNextAccountIndex(accounts, walletId), name: state.name, passphrase: Buffer.from(state.password, 'utf8'), + walletId, }); - capture(Events.SettingsNewAccountConfirmClick); + await capture(Events.SettingsNewAccountConfirmClick); onClose(); } catch { setState(s => ({ ...s, wrongPassword: true })); @@ -716,6 +741,8 @@ const NewAccountModal = React.forwardRef< ); }); +NewAccountModal.displayName = 'NewAccountModal'; + const DeleteAccountModal = React.forwardRef< unknown, { @@ -742,7 +769,10 @@ const DeleteAccountModal = React.forwardRef< a => a.index !== activeAccount.index && a.walletId === activeAccount.walletId, - ) ?? accounts.find(a => !a.hw), + ) ?? + accounts.find( + a => a.type !== WalletType.Ledger && a.type !== WalletType.Trezor, + ), [accounts, activeAccount], ); @@ -810,6 +840,8 @@ const DeleteAccountModal = React.forwardRef< ); }); +DeleteAccountModal.displayName = 'DeleteAccountModal'; + const DelegationPopover = ({ builderRef }) => { const { inMemoryWallet, @@ -875,7 +907,9 @@ const DelegationPopover = ({ builderRef }) => { fontSize="md" textDecoration="underline" cursor="pointer" - onClick={() => openExternalLink(delegation.homepage)} + onClick={() => { + openExternalLink(delegation.homepage); + }} > {delegation.ticker} @@ -956,4 +990,6 @@ const DelegationPopover = ({ builderRef }) => { ); }; +DelegationPopover.displayName = 'DelegationPopover'; + export default Wallet; diff --git a/packages/nami/src/ui/indexMain.tsx b/packages/nami/src/ui/indexMain.tsx index 2a4ccf458..02bce8bfb 100644 --- a/packages/nami/src/ui/indexMain.tsx +++ b/packages/nami/src/ui/indexMain.tsx @@ -9,6 +9,8 @@ import { useBalance } from '../adapters/balance'; import { useFiatCurrency } from '../adapters/currency'; import { useChangePassword } from '../adapters/wallet'; +import { HWConnectFlow } from './app/hw/hw'; +import { SuccessAndClose } from './app/hw/success-and-close'; import Send from './app/pages/send'; import Settings from './app/pages/settings'; import Wallet from './app/pages/wallet'; @@ -48,6 +50,7 @@ export const Main = () => { isValidURL, setAvatar, switchWalletMode, + openHWFlow, } = useOutsideHandles(); const { currency, setCurrency } = useFiatCurrency( @@ -69,7 +72,6 @@ export const Main = () => { }); const { - nextIndex, allAccounts, activeAccount, nonActiveAccounts, @@ -132,10 +134,18 @@ export const Main = () => { withSignTxConfirmation={withSignTxConfirmation} /> + + + + + + { assets={assets} nfts={nfts} setAvatar={setAvatar} + openHWFlow={openHWFlow} />