diff --git a/packages/core-mobile/app/new/common/components/WalletCard.tsx b/packages/core-mobile/app/new/common/components/WalletCard.tsx index f8bf395618..3ae03ac54e 100644 --- a/packages/core-mobile/app/new/common/components/WalletCard.tsx +++ b/packages/core-mobile/app/new/common/components/WalletCard.tsx @@ -12,7 +12,7 @@ import { useManageWallet } from 'common/hooks/useManageWallet' import { AccountDisplayData, WalletDisplayData } from 'common/types' import { AccountListItem } from 'features/wallets/components/AccountListItem' import { WalletBalance } from 'features/wallets/components/WalletBalance' -import React, { useCallback, useState } from 'react' +import React, { useCallback, useMemo, useState } from 'react' import { FlatList, LayoutChangeEvent, @@ -22,9 +22,6 @@ import { } from 'react-native' import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated' import { WalletType } from 'services/wallet/types' -import { LedgerAppType } from 'services/ledger/types' -import { LedgerConnectionCaption } from 'features/accountSettings/components/LedgerConnectionCaption' -import { useObserveLedgerState } from 'common/hooks/useObserveLedgerState' import { DropdownMenu } from './DropdownMenu' import { WalletIcon } from './WalletIcon' @@ -58,10 +55,12 @@ const WalletCard = ({ isAddingAccount } = useManageWallet() - const { isAppOpened, isLedger } = useObserveLedgerState( - wallet.id, - LedgerAppType.AVALANCHE - ) + const isLedger = useMemo(() => { + return ( + wallet?.type === WalletType.LEDGER_LIVE || + wallet?.type === WalletType.LEDGER + ) + }, [wallet?.type]) const renderExpansionIcon = useCallback(() => { return ( @@ -84,7 +83,7 @@ const WalletCard = ({ /> ) }, - [isRefreshing] + [isRefreshing, wallet.name] ) const renderEmpty = useCallback(() => { @@ -153,34 +152,27 @@ const WalletCard = ({ /> {wallet.type !== WalletType.PRIVATE_KEY ? ( - - {((isLedger && isAppOpened) || !isLedger) && ( - - )} - {isLedger && !isAppOpened && ( - + ) : ( <> )} @@ -277,7 +269,7 @@ const WalletCard = ({ groups={[ { key: 'wallet-actions', - items: getDropdownItems(wallet, isLedger && isAppOpened) + items: getDropdownItems(wallet, isLedger) } ]} onPressAction={(event: { nativeEvent: { event: string } }) => diff --git a/packages/core-mobile/app/new/common/hooks/useDeleteWallet.ts b/packages/core-mobile/app/new/common/hooks/useDeleteWallet.ts index 6c0559133c..297d2384ee 100644 --- a/packages/core-mobile/app/new/common/hooks/useDeleteWallet.ts +++ b/packages/core-mobile/app/new/common/hooks/useDeleteWallet.ts @@ -8,9 +8,9 @@ import { resetLoginAttempt } from 'store/security' export const useDeleteWallet = (): { deleteWallet: () => void } => { - const { resetLedgerWalletMap } = useLedgerWalletMap() const { deleteRecentAccounts } = useRecentAccounts() const dispatch = useDispatch() + const { resetLedgerWalletMap } = useLedgerWalletMap() const deleteWallet = useCallback(() => { dispatch(onLogOut()) diff --git a/packages/core-mobile/app/new/common/hooks/useManageWallet.ts b/packages/core-mobile/app/new/common/hooks/useManageWallet.ts index 9406572cbd..52e74a589e 100644 --- a/packages/core-mobile/app/new/common/hooks/useManageWallet.ts +++ b/packages/core-mobile/app/new/common/hooks/useManageWallet.ts @@ -4,7 +4,7 @@ import { dismissAlertWithTextInput, showAlertWithTextInput } from 'common/utils/alertWithTextInput' -import { useLedgerWalletMap } from 'features/ledger/store' +import { useRouter } from 'expo-router' import { showSnackbar } from 'new/common/utils/toast' import { useCallback, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' @@ -22,6 +22,7 @@ import { import { removeWallet } from 'store/wallet/thunks' import { Wallet } from 'store/wallet/types' import Logger from 'utils/Logger' +import { useLedgerWalletMap } from 'features/ledger/store' export const useManageWallet = (): { handleAddAccount: (wallet: Wallet) => void @@ -30,6 +31,7 @@ export const useManageWallet = (): { isAddingAccount: boolean } => { const { removeLedgerWallet } = useLedgerWalletMap() + const { navigate } = useRouter() const [isAddingAccount, setIsAddingAccount] = useState(false) const dispatch = useDispatch() const walletsCount = useSelector(selectWalletsCount) @@ -115,7 +117,7 @@ export const useManageWallet = (): { ] }) }, - [dispatch, walletsCount, removeLedgerWallet] + [dispatch, removeLedgerWallet, walletsCount] ) const handleAddAccount = useCallback( @@ -123,6 +125,24 @@ export const useManageWallet = (): { if (isAddingAccount) return try { + if ( + wallet.type === WalletType.LEDGER || + wallet.type === WalletType.LEDGER_LIVE + ) { + setIsAddingAccount(true) + navigate({ + // @ts-ignore TODO: make routes typesafe + pathname: '/addAccountAppConnection', + params: { walletId: wallet.id } + }) + // Reset the flag after navigation to allow future attempts + // The modal dismissal will naturally reset this state + setTimeout(() => { + setIsAddingAccount(false) + }, 1000) + return + } + AnalyticsService.capture('AccountSelectorAddAccount', { accountNumber: Object.keys(accounts).length + 1 }) @@ -142,7 +162,7 @@ export const useManageWallet = (): { setIsAddingAccount(false) } }, - [isAddingAccount, accounts, dispatch] + [isAddingAccount, accounts, dispatch, navigate] ) const canRemoveWallet = useCallback( diff --git a/packages/core-mobile/app/new/common/hooks/useObserveLedgerState.ts b/packages/core-mobile/app/new/common/hooks/useObserveLedgerState.ts deleted file mode 100644 index 4089d4e48f..0000000000 --- a/packages/core-mobile/app/new/common/hooks/useObserveLedgerState.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { useLedgerWallet } from 'features/ledger/hooks/useLedgerWallet' -import { useLedgerWalletMap } from 'features/ledger/store' -import { LedgerAppType } from 'services/ledger/types' -import { useEffect, useMemo, useState } from 'react' -import LedgerService from 'services/ledger/LedgerService' -import { useSelector } from 'react-redux' -import { selectWalletById } from 'store/wallet/slice' -import { WalletType } from 'services/wallet/types' - -export const useObserveLedgerState = ( - walletId: string, - appType: LedgerAppType -): { - isAppOpened: boolean - isLedger: boolean -} => { - const { transportState } = useLedgerWallet() - const { ledgerWalletMap } = useLedgerWalletMap() - const wallet = useSelector(selectWalletById(walletId)) - const [isAppOpened, setIsAppOpened] = useState(false) - - const deviceForWallet = useMemo(() => { - return ledgerWalletMap[walletId] - }, [ledgerWalletMap, walletId]) - - const [isConnected, setIsConnected] = useState(false) - - const isLedger = useMemo(() => { - return ( - wallet?.type === WalletType.LEDGER_LIVE || - wallet?.type === WalletType.LEDGER - ) - }, [wallet?.type]) - - useEffect(() => { - if (isConnected === false) { - setIsAppOpened(false) - return - } - - const intervalId = setInterval(() => { - LedgerService.checkApp(appType) - .then(isOpened => { - setIsAppOpened(isOpened) - }) - .catch(() => { - setIsAppOpened(false) - }) - }, 2000) - - return () => clearInterval(intervalId) - }, [appType, isConnected, transportState.available]) - - useEffect(() => { - async function checkAppIsOpened(): Promise { - if (isLedger && deviceForWallet?.deviceId && transportState.available) { - setIsConnected(false) - try { - await LedgerService.ensureConnection(deviceForWallet.deviceId) - setIsConnected(true) - } catch (error) { - setIsConnected(false) - } - } else { - setIsConnected(false) - } - } - checkAppIsOpened() - }, [deviceForWallet?.deviceId, isLedger, transportState.available]) - - return { isAppOpened, isLedger } -} diff --git a/packages/core-mobile/app/new/features/accountSettings/components/LedgerConnectionCaption.tsx b/packages/core-mobile/app/new/features/accountSettings/components/LedgerConnectionCaption.tsx deleted file mode 100644 index 3a863e2771..0000000000 --- a/packages/core-mobile/app/new/features/accountSettings/components/LedgerConnectionCaption.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Text, useTheme } from '@avalabs/k2-alpine' -import React from 'react' -import { LedgerAppType } from 'services/ledger/types' - -export const LedgerConnectionCaption = ({ - appType -}: { - appType: LedgerAppType -}): React.JSX.Element => { - const { - theme: { colors } - } = useTheme() - return ( - - {`Connect your Ledger device and open the ${appType} app to add an account. Make sure you connect the device that you used to create this wallet.`} - - ) -} diff --git a/packages/core-mobile/app/new/features/accountSettings/components/accountAddresses.tsx b/packages/core-mobile/app/new/features/accountSettings/components/accountAddresses.tsx index c042a31b9b..2872efb738 100644 --- a/packages/core-mobile/app/new/features/accountSettings/components/accountAddresses.tsx +++ b/packages/core-mobile/app/new/features/accountSettings/components/accountAddresses.tsx @@ -21,8 +21,6 @@ import { WalletType } from 'services/wallet/types' import { useSelector } from 'react-redux' import { selectIsSolanaSupportBlocked } from 'store/posthog' import { useRouter } from 'expo-router' -import { LedgerAppType } from 'services/ledger/types' -import { LedgerConnectionCaption } from './LedgerConnectionCaption' export const AccountAddresses = ({ account @@ -94,7 +92,7 @@ export const AccountAddresses = ({ value: isLedger && network.vmName === NetworkVMType.SVM && - (address === undefined || address === '') && + isMissingSolanaAddress && !isSolanaSupportBlocked ? ( ) : ( @@ -114,6 +112,7 @@ export const AccountAddresses = ({ isSolanaSupportBlocked, colors.$surfaceSecondary, isLedger, + isMissingSolanaAddress, account.id, account.addressPVM, account.addressBTC, @@ -140,9 +139,6 @@ export const AccountAddresses = ({ lineHeight: 22 }} /> - {isMissingSolanaAddress && !isSolanaSupportBlocked && ( - - )} ) } diff --git a/packages/core-mobile/app/new/features/ledger/components/LedgerAppConnection.tsx b/packages/core-mobile/app/new/features/ledger/components/LedgerAppConnection.tsx index 8e47e17772..463dd94de3 100644 --- a/packages/core-mobile/app/new/features/ledger/components/LedgerAppConnection.tsx +++ b/packages/core-mobile/app/new/features/ledger/components/LedgerAppConnection.tsx @@ -1,8 +1,7 @@ -import React, { useMemo } from 'react' +import React, { useCallback, useMemo } from 'react' import { View, ActivityIndicator } from 'react-native' import { Text, useTheme, Icons, GroupList } from '@avalabs/k2-alpine' import { LoadingState } from 'common/components/LoadingState' -import { LedgerDerivationPathType, LedgerKeys } from 'services/ledger/types' import { truncateAddress } from '@avalabs/core-utils-sdk' import { TRUNCATE_ADDRESS_LENGTH } from 'common/consts/text' import { NetworkLogoWithChain } from 'common/components/NetworkLogoWithChain' @@ -16,6 +15,8 @@ import { ChainName } from 'services/network/consts' import { stripAddressPrefix } from 'common/utils/stripAddressPrefix' import { selectIsSolanaSupportBlocked } from 'store/posthog' import { useSelector } from 'react-redux' +import { selectIsDeveloperMode } from 'store/settings/advanced' +import { LedgerKeysByNetwork } from 'services/ledger/types' import { LedgerDeviceList } from './LedgerDeviceList' import { AnimatedIconWithText } from './AnimatedIconWithText' @@ -36,20 +37,17 @@ interface StepConfig { } interface LedgerAppConnectionProps { - deviceName: string - selectedDerivationPath: LedgerDerivationPathType | null - isCreatingWallet?: boolean + completeStepTitle?: string connectedDeviceId?: string | null connectedDeviceName?: string - keys?: LedgerKeys + keys?: LedgerKeysByNetwork appConnectionStep: AppConnectionStep skipSolana?: boolean onlySolana?: boolean } export const LedgerAppConnection: React.FC = ({ - deviceName, - selectedDerivationPath: _selectedDerivationPath, + completeStepTitle = `Your Ledger wallet\nis being set up`, connectedDeviceId, connectedDeviceName, keys, @@ -60,23 +58,27 @@ export const LedgerAppConnection: React.FC = ({ const { theme: { colors } } = useTheme() + const isDeveloperMode = useSelector(selectIsDeveloperMode) const isSolanaSupportBlocked = useSelector(selectIsSolanaSupportBlocked) + const deviceName = connectedDeviceName || 'Ledger Device' + const keysByNetwork = isDeveloperMode ? keys?.testnet : keys?.mainnet const hasAllKeys = useMemo(() => { return ( - !!keys?.avalancheKeys && - keys?.bitcoinAddress !== '' && - keys?.xpAddress !== '' && + !!keysByNetwork?.avalancheKeys?.addresses.pvm && + !!keysByNetwork?.avalancheKeys?.addresses.avm && + !!keysByNetwork?.avalancheKeys?.addresses.evm && + !!keysByNetwork?.avalancheKeys?.addresses.btc && + // Solana keys are optional, so we don't require them to consider the wallet "ready" + (isSolanaSupportBlocked || skipSolana || - (keys?.solanaKeys && keys.solanaKeys.length > 0)) + (keysByNetwork?.solanaKeys && keysByNetwork.solanaKeys.length > 0)) ) }, [ isSolanaSupportBlocked, - keys?.avalancheKeys, - keys?.bitcoinAddress, - keys?.solanaKeys, - keys?.xpAddress, + keysByNetwork?.avalancheKeys?.addresses, + keysByNetwork?.solanaKeys, skipSolana ]) @@ -85,11 +87,11 @@ export const LedgerAppConnection: React.FC = ({ const addresses = [] // C-Chain/EVM address (derived from avalanche keys) - if (keys?.avalancheKeys?.addresses.evm) { + if (keysByNetwork?.avalancheKeys?.addresses.evm) { addresses.push({ title: AVALANCHE_MAINNET_NETWORK.chainName, subtitle: truncateAddress( - keys.avalancheKeys.addresses.evm, + keysByNetwork.avalancheKeys.addresses.evm, TRUNCATE_ADDRESS_LENGTH ), value: ( @@ -111,7 +113,7 @@ export const LedgerAppConnection: React.FC = ({ } // X/P Chain address - if (keys?.xpAddress) { + if (keysByNetwork?.avalancheKeys?.addresses.pvm) { const xpNetwork = { ...AVALANCHE_XP_NETWORK, chainName: ChainName.AVALANCHE_XP @@ -119,7 +121,7 @@ export const LedgerAppConnection: React.FC = ({ addresses.push({ title: xpNetwork.chainName, subtitle: truncateAddress( - stripAddressPrefix(keys.xpAddress), + stripAddressPrefix(keysByNetwork.avalancheKeys.addresses.pvm), TRUNCATE_ADDRESS_LENGTH ), value: ( @@ -141,14 +143,17 @@ export const LedgerAppConnection: React.FC = ({ } // Bitcoin address - if (keys?.bitcoinAddress) { + if (keysByNetwork?.avalancheKeys?.addresses.btc) { const bitcoinNetwork = { ...BITCOIN_NETWORK, chainName: ChainName.BITCOIN } addresses.push({ title: bitcoinNetwork.chainName, - subtitle: truncateAddress(keys.bitcoinAddress, TRUNCATE_ADDRESS_LENGTH), + subtitle: truncateAddress( + keysByNetwork.avalancheKeys.addresses.btc, + TRUNCATE_ADDRESS_LENGTH + ), value: ( = ({ // Solana address if ( - keys?.solanaKeys && - keys?.solanaKeys.length > 0 && - keys?.solanaKeys[0]?.key + keysByNetwork?.solanaKeys && + keysByNetwork.solanaKeys.length > 0 && + keysByNetwork.solanaKeys[0]?.key ) { // The key is already a Solana address (Base58 encoded) from LedgerService - const solanaAddress = keys.solanaKeys[0].key + const solanaAddress = keysByNetwork.solanaKeys[0].key addresses.push({ title: NETWORK_SOLANA.chainName, @@ -213,84 +218,88 @@ export const LedgerAppConnection: React.FC = ({ return addresses }, [ - keys?.avalancheKeys?.addresses.evm, - keys?.xpAddress, - keys?.bitcoinAddress, - keys?.solanaKeys, + keysByNetwork?.avalancheKeys?.addresses.evm, + keysByNetwork?.avalancheKeys?.addresses.pvm, + keysByNetwork?.avalancheKeys?.addresses.btc, + keysByNetwork?.solanaKeys, hasAllKeys, colors.$textSuccess, colors.$surfaceSecondary ]) // Step configurations - const getStepConfig = (step: AppConnectionStep): StepConfig | null => { - switch (step) { - case AppConnectionStep.AVALANCHE_CONNECT: - return { - icon: ( - - ), - title: 'Connect to Avalanche App', - subtitle: `Open the Avalanche app on your ${deviceName}, then press Continue when ready.`, - showAnimation: false - } + const getStepConfig = useCallback( + (step: AppConnectionStep): StepConfig | null => { + switch (step) { + case AppConnectionStep.AVALANCHE_CONNECT: + return { + icon: ( + + ), + title: 'Connect to Avalanche App', + subtitle: `Open the Avalanche app on your ${deviceName}, then press Continue when ready.`, + showAnimation: false, + isLoading: !connectedDeviceId + } - case AppConnectionStep.AVALANCHE_LOADING: - return { - icon: ( - - ), - title: 'Connecting to Avalanche app', - subtitle: `Please keep your Avalanche app open on your ${deviceName}, We're retrieving your Avalanche addresses...`, - showAnimation: true, - isLoading: true - } + case AppConnectionStep.AVALANCHE_LOADING: + return { + icon: ( + + ), + title: 'Connecting to Avalanche app', + subtitle: `Please keep your Avalanche app open on your ${deviceName}, We're retrieving your Avalanche addresses...`, + showAnimation: true, + isLoading: true + } - case AppConnectionStep.SOLANA_CONNECT: - return { - icon: ( - - ), - title: 'Connect to Solana App', - subtitle: onlySolana - ? `Open the Solana app on your ${deviceName}, then press Continue when ready.` - : `Close the Avalanche app and open the Solana app on your ${deviceName}, then press Continue when ready.`, - showAnimation: false - } + case AppConnectionStep.SOLANA_CONNECT: + return { + icon: ( + + ), + title: 'Connect to Solana App', + subtitle: onlySolana + ? `Open the Solana app on your ${deviceName}, then press Continue when ready.` + : `Close the Avalanche app and open the Solana app on your ${deviceName}, then press Continue when ready.`, + showAnimation: false + } - case AppConnectionStep.SOLANA_LOADING: - return { - icon: ( - - ), - title: 'Connecting to Solana', - subtitle: `Please keep your Solana app open on your ${deviceName}, We're retrieving your Solana address...`, - showAnimation: true, - isLoading: true - } + case AppConnectionStep.SOLANA_LOADING: + return { + icon: ( + + ), + title: 'Connecting to Solana', + subtitle: `Please keep your Solana app open on your ${deviceName}, We're retrieving your Solana address...`, + showAnimation: true, + isLoading: true + } - default: - return null - } - } + default: + return null + } + }, + [colors.$textPrimary, connectedDeviceId, deviceName, onlySolana] + ) - const renderStepContent = (): React.ReactNode => { + const renderStepContent = useCallback((): React.ReactNode => { // Handle COMPLETE step separately as it has unique layout if (currentStep === AppConnectionStep.COMPLETE) { return ( @@ -308,7 +317,7 @@ export const LedgerAppConnection: React.FC = ({ - {`Your Ledger wallet \nis being set up`} + {completeStepTitle} @@ -399,11 +408,18 @@ export const LedgerAppConnection: React.FC = ({ ) - } + }, [ + addressListData, + colors.$textPrimary, + colors.$textSecondary, + completeStepTitle, + currentStep, + getStepConfig + ]) // Create device object for display const connectedDevice = connectedDeviceId - ? [{ id: connectedDeviceId, name: connectedDeviceName || deviceName }] + ? [{ id: connectedDeviceId, name: deviceName }] : [] return ( diff --git a/packages/core-mobile/app/new/features/ledger/consts.ts b/packages/core-mobile/app/new/features/ledger/consts.ts index 0bc52ad817..476dfe1775 100644 --- a/packages/core-mobile/app/new/features/ledger/consts.ts +++ b/packages/core-mobile/app/new/features/ledger/consts.ts @@ -1,30 +1,65 @@ // Ledger derivation path constants +export enum DerivationPathKey { + EVM = 'EVM', + AVALANCHE = 'AVALANCHE', + SOLANA = 'SOLANA' +} + export const DERIVATION_PATHS = { // BIP44 Standard paths BIP44: { - EVM: "m/44'/60'/0'/0/0", - AVALANCHE: "m/44'/9000'/0'/0/0", - SOLANA: "m/44'/501'/0'/0", - BITCOIN: "m/44'/0'/0'/0/0" + [DerivationPathKey.EVM]: (accountIndex: number, addressIndex: number) => + `m/44'/60'/${accountIndex}'/0/${addressIndex}`, + [DerivationPathKey.AVALANCHE]: ( + accountIndex: number, + addressIndex: number + ) => `m/44'/9000'/${accountIndex}'/0/${addressIndex}`, + [DerivationPathKey.SOLANA]: (accountIndex: number, addressIndex: number) => + `44'/501'/${accountIndex}'/0/${addressIndex}` }, // Ledger Live paths (account-based) LEDGER_LIVE: { - EVM: (accountIndex: number) => `m/44'/60'/${accountIndex}'/0/0`, - AVALANCHE: (accountIndex: number) => `m/44'/9000'/${accountIndex}'/0/0`, - SOLANA: (accountIndex: number) => `44'/501'/${accountIndex}'/0`, - BITCOIN: (accountIndex: number) => `m/44'/0'/${accountIndex}'/0/0` + [DerivationPathKey.EVM]: (accountIndex: number) => + `m/44'/60'/${accountIndex}'/0/0`, + [DerivationPathKey.AVALANCHE]: (accountIndex: number) => + `m/44'/9000'/${accountIndex}'/0/0`, + [DerivationPathKey.SOLANA]: (accountIndex: number) => + `44'/501'/${accountIndex}'/0` }, // Extended public key paths (without final /0/0) EXTENDED: { - EVM: "m/44'/60'/0'", - AVALANCHE: "m/44'/9000'/0'", - BITCOIN: "m/44'/0'/0'", - SOLANA: "m/44'/501'/0'" + [DerivationPathKey.EVM]: "m/44'/60'/0'", + [DerivationPathKey.AVALANCHE]: "m/44'/9000'/0'", + [DerivationPathKey.SOLANA]: "m/44'/501'/0'" } } as const +/** + * Generate a Ledger derivation path based on the specified key and indices + * @param key - The type of derivation path to generate (EVM, Avalanche, Solana) + * @param accountIndex - The account index to generate the path for + * @param addressIndex - The address index to generate the path for (default: 0) + * @returns The complete derivation path string + */ +export const getLedgerDerivationPath = ( + key: DerivationPathKey, + accountIndex: number, + addressIndex = 0 +): string => { + switch (key) { + case DerivationPathKey.EVM: + return DERIVATION_PATHS.BIP44.EVM(accountIndex, addressIndex) + case DerivationPathKey.AVALANCHE: + return DERIVATION_PATHS.BIP44.AVALANCHE(accountIndex, addressIndex) + case DerivationPathKey.SOLANA: + return DERIVATION_PATHS.BIP44.SOLANA(accountIndex, addressIndex) + default: + throw new Error(`Unsupported derivation path key: ${key}`) + } +} + /** * Generate a Solana derivation path for a specific account index * @param accountIndex - The account index to generate the path for diff --git a/packages/core-mobile/app/new/features/ledger/contexts/LedgerSetupContext.tsx b/packages/core-mobile/app/new/features/ledger/contexts/LedgerSetupContext.tsx index 341d41a2fe..962c2809ee 100644 --- a/packages/core-mobile/app/new/features/ledger/contexts/LedgerSetupContext.tsx +++ b/packages/core-mobile/app/new/features/ledger/contexts/LedgerSetupContext.tsx @@ -8,37 +8,26 @@ import React, { } from 'react' import { LedgerDerivationPathType, - WalletCreationOptions, - LedgerTransportState, - LedgerKeys, - WalletUpdateOptions + LedgerTransportState } from 'services/ledger/types' import { useLedgerWallet } from '../hooks/useLedgerWallet' -import { useLedgerWalletMap } from '../store' interface LedgerSetupContextValue { // State values selectedDerivationPath: LedgerDerivationPathType | null connectedDeviceId: string | null connectedDeviceName: string - isCreatingWallet: boolean - hasStartedSetup: boolean + isUpdatingWallet: boolean // State setters setSelectedDerivationPath: (path: LedgerDerivationPathType) => void - setConnectedDevice: (deviceId: string, deviceName: string) => void - setIsCreatingWallet: (creating: boolean) => void - setHasStartedSetup: (started: boolean) => void + setIsUpdatingWallet: (creating: boolean) => void // Ledger wallet hook values isConnecting: boolean transportState: LedgerTransportState - connectToDevice: (deviceId: string) => Promise + connectToDevice: (deviceId: string, deviceName?: string) => Promise disconnectDevice: () => Promise - createLedgerWallet: ( - options: WalletCreationOptions & LedgerKeys - ) => Promise<{ walletId: string; accountId: string }> - updateSolanaForLedgerWallet: (options: WalletUpdateOptions) => Promise // Helper methods resetSetup: () => void } @@ -59,34 +48,10 @@ export const LedgerSetupProvider: React.FC = ({ ) const [connectedDeviceName, setConnectedDeviceName] = useState('Ledger Device') - const [isCreatingWallet, setIsCreatingWallet] = useState(false) - const [hasStartedSetup, setHasStartedSetup] = useState(false) + const [isUpdatingWallet, setIsUpdatingWallet] = useState(false) - const { - isConnecting, - transportState, - connectToDevice, - disconnectDevice, - createLedgerWallet: _createLedgerWallet, - updateSolanaForLedgerWallet - } = useLedgerWallet() - - const { setLedgerWalletMap } = useLedgerWalletMap() - - const createLedgerWallet = useCallback( - async ( - options: WalletCreationOptions - ): Promise<{ walletId: string; accountId: string }> => { - const { walletId, accountId } = await _createLedgerWallet(options) - setLedgerWalletMap( - walletId, - options.deviceId, - options.deviceName || 'Ledger Device' - ) - return { walletId, accountId } - }, - [_createLedgerWallet, setLedgerWalletMap] - ) + const { isConnecting, transportState, connectToDevice, disconnectDevice } = + useLedgerWallet() const handleSetConnectedDevice = useCallback( (deviceId: string, deviceName: string) => { @@ -100,42 +65,45 @@ export const LedgerSetupProvider: React.FC = ({ setSelectedDerivationPath(null) setConnectedDeviceId(null) setConnectedDeviceName('Ledger Device') - setIsCreatingWallet(false) - setHasStartedSetup(false) + setIsUpdatingWallet(false) }, []) + const handleConnectToDevice = useCallback( + async (deviceId: string, deviceName?: string) => { + await connectToDevice(deviceId) + handleSetConnectedDevice(deviceId, deviceName || 'Ledger Device') + }, + [connectToDevice, handleSetConnectedDevice] + ) + + const handleDisconnectDevice = useCallback(async () => { + await disconnectDevice() + resetSetup() + }, [disconnectDevice, resetSetup]) + const contextValue: LedgerSetupContextValue = useMemo( () => ({ selectedDerivationPath, connectedDeviceId, connectedDeviceName, - isCreatingWallet, - hasStartedSetup, + isUpdatingWallet, isConnecting, transportState, - connectToDevice, - disconnectDevice, - createLedgerWallet, - updateSolanaForLedgerWallet, + connectToDevice: handleConnectToDevice, + disconnectDevice: handleDisconnectDevice, setSelectedDerivationPath, - setConnectedDevice: handleSetConnectedDevice, - setIsCreatingWallet, - setHasStartedSetup, + setIsUpdatingWallet, resetSetup }), [ selectedDerivationPath, connectedDeviceId, connectedDeviceName, - isCreatingWallet, - hasStartedSetup, + isUpdatingWallet, isConnecting, transportState, - connectToDevice, - disconnectDevice, - createLedgerWallet, - updateSolanaForLedgerWallet, - handleSetConnectedDevice, + handleConnectToDevice, + handleDisconnectDevice, resetSetup ] ) diff --git a/packages/core-mobile/app/new/features/ledger/hooks/useLedgerWallet.ts b/packages/core-mobile/app/new/features/ledger/hooks/useLedgerWallet.ts index cb25a2eb41..e171ef8fad 100644 --- a/packages/core-mobile/app/new/features/ledger/hooks/useLedgerWallet.ts +++ b/packages/core-mobile/app/new/features/ledger/hooks/useLedgerWallet.ts @@ -9,8 +9,10 @@ import { LedgerDerivationPathType, LedgerKeys, LedgerTransportState, + PublicKeyInfo, WalletCreationOptions, - WalletUpdateOptions + WalletUpdateOptions, + WalletUpdateSolanaOptions } from 'services/ledger/types' import { WalletType } from 'services/wallet/types' import { PrimaryAccount, setAccount, setActiveAccountId } from 'store/account' @@ -18,12 +20,13 @@ import { AppThunkDispatch } from 'store/types' import { setActiveWallet } from 'store/wallet/slice' import { storeWallet } from 'store/wallet/thunks' import Logger from 'utils/Logger' -import { Curve } from 'utils/publicKeys' import { uuid } from 'utils/uuid' import { CoreAccountType } from '@avalabs/types' import BiometricsSDK from 'utils/BiometricsSDK' -import { DERIVATION_PATHS } from '../consts' +import { Curve } from 'utils/publicKeys' import { LedgerWalletSecretSchema } from '../utils' +import { useLedgerWalletMap } from '../store' +import { DerivationPathKey, getLedgerDerivationPath } from '../consts' export interface UseLedgerWalletReturn { // Connection state @@ -37,10 +40,16 @@ export interface UseLedgerWalletReturn { createLedgerWallet: ( options: WalletCreationOptions & LedgerKeys ) => Promise<{ walletId: string; accountId: string }> - updateSolanaForLedgerWallet: (options: WalletUpdateOptions) => Promise + updateSolanaForLedgerWallet: ( + options: WalletUpdateSolanaOptions + ) => Promise + createLedgerAccount: ( + options: WalletUpdateOptions & LedgerKeys + ) => Promise<{ walletId: string; accountId: string }> } export function useLedgerWallet(): UseLedgerWalletReturn { + const { setLedgerWalletMap } = useLedgerWalletMap() const dispatch = useDispatch() const isLedgerBlocked = useSelector(selectIsLedgerSupportBlocked) const [transportState, setTransportState] = useState({ @@ -82,7 +91,7 @@ export function useLedgerWallet(): UseLedgerWalletReturn { const connectToDevice = useCallback(async (deviceId: string) => { setIsConnecting(true) try { - await LedgerService.connect(deviceId) + await LedgerService.ensureConnection(deviceId) Logger.info('Connected to Ledger device') } catch (error) { Logger.error('Failed to connect to device', error) @@ -108,10 +117,8 @@ export function useLedgerWallet(): UseLedgerWalletReturn { deviceId, deviceName = 'Ledger Device', derivationPathType = LedgerDerivationPathType.BIP44, - individualKeys = [], avalancheKeys, - solanaKeys = [], - bitcoinAddress + solanaKeys = [] }: WalletCreationOptions & LedgerKeys) => { try { setIsLoading(true) @@ -123,6 +130,7 @@ export function useLedgerWallet(): UseLedgerWalletReturn { if (!avalancheKeys) { throw new Error('Missing Avalanche keys for wallet creation') } + // Solana keys are optional - wallet can be created with only Avalanche keys const newWalletId = uuid() @@ -130,51 +138,14 @@ export function useLedgerWallet(): UseLedgerWalletReturn { // Use addresses for display and xpubs for wallet functionality const { addresses, xpubs } = avalancheKeys - // Fix address formatting - remove double 0x prefixes that cause VM module errors - const formattedAddresses = { - evm: addresses.evm?.startsWith('0x0x') - ? addresses.evm.slice(2) // Remove first 0x to fix double prefix - : addresses.evm, - avm: addresses.avm, - pvm: addresses.pvm - } - - // Also fix the public keys array to ensure no double prefixes in storage - const formattedPublicKeys = individualKeys.map(key => ({ - ...key, - key: key.key?.startsWith('0x0x') ? key.key.slice(2) : key.key - })) - - // Create the public keys array for BIP44 - const publicKeysToStore = [ - // Use formatted addresses for BIP44 - { - key: formattedAddresses.evm, // Use formatted address - derivationPath: DERIVATION_PATHS.BIP44.EVM, - curve: Curve.SECP256K1 - }, - { - key: formattedAddresses.avm, - derivationPath: DERIVATION_PATHS.BIP44.AVALANCHE, - curve: Curve.SECP256K1 - }, - { - key: formattedAddresses.pvm, - derivationPath: DERIVATION_PATHS.BIP44.AVALANCHE, - curve: Curve.SECP256K1 - }, - // Only include Solana key if it exists - ...(solanaKeys.length > 0 && solanaKeys[0]?.key - ? [ - { - key: solanaKeys[0].key, // Solana addresses don't use 0x prefix - derivationPath: solanaKeys[0].derivationPath, // Use the same path from getSolanaKeys - curve: Curve.ED25519 - } - ] - : []) - ] + const formattedAddresses = getFormattedAddresses(addresses) + // Create the public keys array + const publicKeysToStore = getPublicKeysForAccount( + formattedAddresses, + solanaKeys, + 0 // For wallet creation, we are only adding the first account (index 0) + ) // Store the Ledger wallet with the specified derivation path type // For BIP44, store xpub in per-account format for future account additions await dispatch( @@ -195,13 +166,7 @@ export function useLedgerWallet(): UseLedgerWalletReturn { } } }), - publicKeys: - derivationPathType === LedgerDerivationPathType.LedgerLive && - individualKeys.length > 0 - ? formattedPublicKeys // Use formatted individual keys for Ledger Live - : publicKeysToStore, // Use the public keys we just created - avalancheKeys: formattedAddresses, // Use formatted addresses for display - solanaKeys + publicKeys: publicKeysToStore }), type: derivationPathType === LedgerDerivationPathType.BIP44 @@ -210,6 +175,12 @@ export function useLedgerWallet(): UseLedgerWalletReturn { }) ).unwrap() + setLedgerWalletMap( + newWalletId, + { id: deviceId, name: deviceName || 'Ledger Device' }, + derivationPathType + ) + dispatch(setActiveWallet(newWalletId)) // For the first account (index 0), use the addresses we retrieved during setup @@ -222,11 +193,11 @@ export function useLedgerWallet(): UseLedgerWalletReturn { type: CoreAccountType.PRIMARY, index: 0, addressC: formattedAddresses.evm, - addressBTC: bitcoinAddress || '', + addressBTC: formattedAddresses.btc, addressAVM: formattedAddresses.avm, addressPVM: formattedAddresses.pvm, addressSVM: solanaKeys[0]?.key || '', - addressCoreEth: '' + addressCoreEth: formattedAddresses.coreEth } dispatch(setAccount(newAccount)) @@ -242,18 +213,136 @@ export function useLedgerWallet(): UseLedgerWalletReturn { setIsLoading(false) } }, - [dispatch] + [dispatch, setLedgerWalletMap] ) - const updateSolanaForLedgerWallet = useCallback( + const createLedgerAccount = useCallback( async ({ deviceId, + deviceName, + derivationPathType, walletId, walletName, walletType, + accountIndexToUse, + avalancheKeys, + solanaKeys = [] + }: WalletUpdateOptions & LedgerKeys) => { + try { + setIsLoading(true) + + if (!avalancheKeys) { + throw new Error('Missing Avalanche keys for account creation') + } + + // Use addresses for display and xpubs for wallet functionality + const { addresses, xpubs } = avalancheKeys + + const formattedAddresses = getFormattedAddresses(addresses) + + // Create the public keys array + const publicKeysToUpdate = getPublicKeysForAccount( + formattedAddresses, + solanaKeys, + accountIndexToUse + ) + + const walletSecretResult = await BiometricsSDK.loadWalletSecret( + walletId + ) + + if ( + walletSecretResult.success === false || + walletSecretResult.value === undefined + ) { + throw new Error('Failed to load existing wallet secret for update') + } + + const parsedWalletSecret = LedgerWalletSecretSchema.parse( + JSON.parse(walletSecretResult.value) + ) + + if (deviceId !== parsedWalletSecret.deviceId) { + throw new Error( + 'Device ID mismatch between connected wallet and stored wallet' + ) + } + + // Destructure to explicitly omit extendedPublicKeys from spread + // This ensures LedgerLive wallets don't preserve invalid extendedPublicKeys + const { extendedPublicKeys, publicKeys, ...baseWalletSecret } = + parsedWalletSecret + + // Update the Ledger wallet extended public keys for new account + await dispatch( + storeWallet({ + walletId, + name: walletName, + type: walletType, + walletSecret: JSON.stringify({ + ...baseWalletSecret, + // For BIP44, update the extended public keys for account index + ...(baseWalletSecret.derivationPathSpec === + LedgerDerivationPathType.BIP44 && { + extendedPublicKeys: { + ...extendedPublicKeys, + [accountIndexToUse]: { + evm: xpubs.evm, // Update with new xpub from getAvalancheKeys + avalanche: xpubs.avalanche // Update with new xpub from getAvalancheKeys + } + } + }), + publicKeys: [...publicKeys, ...publicKeysToUpdate] // Append new account public keys to existing array + }) + }) + ).unwrap() + + setLedgerWalletMap( + walletId, + { id: deviceId, name: deviceName || 'Ledger Device' }, + derivationPathType + ) + + const newAccountId = uuid() + const updatedAccount: PrimaryAccount = { + id: newAccountId, + walletId, + name: `Account ${accountIndexToUse + 1}`, + type: CoreAccountType.PRIMARY, + index: accountIndexToUse, + addressC: formattedAddresses.evm, + addressCoreEth: formattedAddresses.coreEth, + addressAVM: formattedAddresses.avm, + addressPVM: formattedAddresses.pvm, + addressBTC: formattedAddresses.btc, + addressSVM: solanaKeys[0]?.key || '' + } + + dispatch(setAccount(updatedAccount)) + dispatch(setActiveAccountId(newAccountId)) + + Logger.info('Account created successfully') + showSnackbar('Account created successfully!') + return { walletId, accountId: newAccountId } + } catch (error) { + Logger.error('Failed to create account:', error) + throw error + } finally { + setIsLoading(false) + } + }, + [dispatch, setLedgerWalletMap] + ) + + const updateSolanaForLedgerWallet = useCallback( + async ({ + deviceId, + walletId, account, + walletName, + walletType, solanaKeys = [] - }: WalletUpdateOptions) => { + }: WalletUpdateSolanaOptions) => { try { setIsLoading(true) @@ -282,34 +371,32 @@ export function useLedgerWallet(): UseLedgerWalletReturn { ) } - // Create the public keys array for BIP44 - const publicKeysToStore = [ - // Use formatted addresses for BIP44 - ...parsedWalletSecret.publicKeys, - // Only include Solana key if it exists - { - key: solanaKeys[0].key, // Solana addresses don't use 0x prefix - derivationPath: solanaKeys[0].derivationPath, // Use the same path from getSolanaKeys - curve: Curve.ED25519 - } - ] + const { publicKeys, ...baseWalletSecret } = parsedWalletSecret - // Store the Ledger wallet with the specified derivation path type + // Update the Ledger wallet extended public keys for new account await dispatch( storeWallet({ walletId, name: walletName, type: walletType, walletSecret: JSON.stringify({ - ...parsedWalletSecret, - publicKeys: publicKeysToStore, - solanaKeys + ...baseWalletSecret, + publicKeys: [ + ...publicKeys, + ...(solanaKeys.length > 0 && solanaKeys[0]?.key + ? [ + { + key: solanaKeys[0].key, // Solana addresses don't use 0x prefix + derivationPath: solanaKeys[0].derivationPath, // Use the same path from getSolanaKeys + curve: Curve.ED25519 + } + ] + : []) + ] // Append new account public keys to existing array }) }) ).unwrap() - // For the first account (index 0), use the addresses we retrieved during setup - // This avoids the complex derivation logic that returns empty addresses const updatedAccount: PrimaryAccount = { ...account, addressSVM: solanaKeys[0]?.key @@ -336,6 +423,84 @@ export function useLedgerWallet(): UseLedgerWalletReturn { disconnectDevice, isLoading, createLedgerWallet, - updateSolanaForLedgerWallet + updateSolanaForLedgerWallet, + createLedgerAccount } } + +// Fix address formatting - remove double 0x prefixes that cause VM module errors +const getFormattedAddresses = (address: { + evm: string + avm: string + pvm: string + btc: string + coreEth: string +}): { + evm: string + avm: string + pvm: string + btc: string + coreEth: string +} => { + return { + evm: address.evm?.startsWith('0x0x') + ? address.evm.slice(2) // Remove first 0x to fix double prefix + : address.evm, + avm: address.avm, + pvm: address.pvm, + btc: address.btc, + coreEth: address.coreEth?.startsWith('0x0x') + ? address.coreEth.slice(2) // Remove first 0x to fix double prefix + : address.coreEth + } +} + +const getPublicKeysForAccount = ( + address: { + evm: string + avm: string + pvm: string + btc: string + coreEth: string + }, + solanaKeys: PublicKeyInfo[], + accountIndex = 0 +): PublicKeyInfo[] => { + return [ + // Use formatted addresses + { + key: address.evm, // Use formatted address + derivationPath: getLedgerDerivationPath( + DerivationPathKey.EVM, + accountIndex + ), + curve: Curve.SECP256K1 + }, + { + key: address.avm, + derivationPath: getLedgerDerivationPath( + DerivationPathKey.AVALANCHE, + accountIndex + ), + curve: Curve.SECP256K1 + }, + { + key: address.pvm, + derivationPath: getLedgerDerivationPath( + DerivationPathKey.AVALANCHE, + accountIndex + ), + curve: Curve.SECP256K1 + }, + // Only include Solana key if it exists + ...(solanaKeys.length > 0 && solanaKeys[0]?.key + ? [ + { + key: solanaKeys[0].key, // Solana addresses don't use 0x prefix + derivationPath: solanaKeys[0].derivationPath, // Use the same path from getSolanaKeys + curve: Curve.ED25519 + } + ] + : []) + ] +} diff --git a/packages/core-mobile/app/new/features/ledger/hooks/useSetLedgerAddress.ts b/packages/core-mobile/app/new/features/ledger/hooks/useSetLedgerAddress.ts new file mode 100644 index 0000000000..661f4c5b88 --- /dev/null +++ b/packages/core-mobile/app/new/features/ledger/hooks/useSetLedgerAddress.ts @@ -0,0 +1,63 @@ +import { useCallback } from 'react' +import { useDispatch } from 'react-redux' +import { LedgerKeysByNetwork } from 'services/ledger/types' +import { setLedgerAddresses } from 'store/account' + +export const useSetLedgerAddress = (): { + setLedgerAddress: ({ + walletId, + accountId, + accountIndex, + keys + }: { + walletId: string + accountId: string + accountIndex: number + keys: LedgerKeysByNetwork + }) => Promise +} => { + const dispatch = useDispatch() + + const setLedgerAddress = useCallback( + async ({ + walletId, + accountId, + accountIndex, + keys + }: { + walletId: string + accountId: string + accountIndex: number + keys: LedgerKeysByNetwork + }) => { + const mainnet = { + addressBTC: keys.mainnet.avalancheKeys?.addresses.btc ?? '', + addressAVM: keys.mainnet.avalancheKeys?.addresses.avm || '', + addressPVM: keys.mainnet.avalancheKeys?.addresses.pvm || '', + addressCoreEth: keys.mainnet.avalancheKeys?.addresses.coreEth || '' + } + + const testnet = { + addressBTC: keys.testnet.avalancheKeys?.addresses.btc ?? '', + addressAVM: keys.testnet.avalancheKeys?.addresses.avm || '', + addressPVM: keys.testnet.avalancheKeys?.addresses.pvm || '', + addressCoreEth: keys.testnet.avalancheKeys?.addresses.coreEth || '' + } + + dispatch( + setLedgerAddresses({ + [accountId]: { + mainnet, + testnet, + walletId, + index: accountIndex, + id: accountId + } + }) + ) + }, + [dispatch] + ) + + return { setLedgerAddress } +} diff --git a/packages/core-mobile/app/new/features/ledger/screens/AppConnectionAddAccountScreen.tsx b/packages/core-mobile/app/new/features/ledger/screens/AppConnectionAddAccountScreen.tsx new file mode 100644 index 0000000000..ed9ab484d3 --- /dev/null +++ b/packages/core-mobile/app/new/features/ledger/screens/AppConnectionAddAccountScreen.tsx @@ -0,0 +1,129 @@ +import React, { useCallback, useMemo } from 'react' +import { useSelector } from 'react-redux' +import { useLocalSearchParams, useRouter } from 'expo-router' +import { LedgerKeysByNetwork } from 'services/ledger/types' +import Logger from 'utils/Logger' +import { selectIsDeveloperMode } from 'store/settings/advanced' +import LedgerService from 'services/ledger/LedgerService' +import { selectWalletById } from 'store/wallet/slice' +import { RootState } from 'store/types' +import { selectAccountsByWalletId } from 'store/account' +import { showSnackbar } from 'common/utils/toast' +import { useLedgerWallet } from '../hooks/useLedgerWallet' +import { useSetLedgerAddress } from '../hooks/useSetLedgerAddress' +import { useLedgerSetupContext } from '../contexts/LedgerSetupContext' +import { useLedgerWalletMap } from '../store' +import AppConnectionScreen from './AppConnectionScreen' + +export const AppConnectionAddAccountScreen = (): JSX.Element => { + const { walletId } = useLocalSearchParams<{ walletId: string }>() + const { dismiss } = useRouter() + const { createLedgerAccount } = useLedgerWallet() + const { setLedgerAddress } = useSetLedgerAddress() + const isDeveloperMode = useSelector(selectIsDeveloperMode) + const wallet = useSelector(selectWalletById(walletId)) + const accounts = useSelector((state: RootState) => + selectAccountsByWalletId(state, walletId) + ) + const { getLedgerInfoByWalletId } = useLedgerWalletMap() + + const { device, derivationPathType } = useMemo(() => { + return getLedgerInfoByWalletId(walletId) + }, [getLedgerInfoByWalletId, walletId]) + + const { + resetSetup, + disconnectDevice, + isUpdatingWallet, + setIsUpdatingWallet + } = useLedgerSetupContext() + + const handleComplete = useCallback( + async (keys: LedgerKeysByNetwork) => { + const keysByNetwork = isDeveloperMode ? keys.testnet : keys.mainnet + if ( + keysByNetwork.avalancheKeys && + device && + wallet && + accounts?.length > 0 && + derivationPathType && + !isUpdatingWallet + ) { + Logger.info('All conditions met, creating account...') + setIsUpdatingWallet(true) + + try { + const { accountId } = await createLedgerAccount({ + walletId: wallet.id, + walletName: wallet.name, + walletType: wallet.type, + accountIndexToUse: accounts?.length ?? 0, + deviceId: device.id, + deviceName: device.name, + derivationPathType, + avalancheKeys: keysByNetwork.avalancheKeys, + solanaKeys: keysByNetwork.solanaKeys + }) + + await setLedgerAddress({ + accountIndex: accounts?.length ?? 0, + walletId, + accountId, + keys + }) + + Logger.info('Account created successfully, dismissing modals') + // Stop polling since we no longer need app detection + LedgerService.stopAppPolling() + showSnackbar('Account added successfully') + } catch (error) { + Logger.error('Account creation failed', error) + showSnackbar('Unable to add account') + } finally { + setIsUpdatingWallet(false) + resetSetup() + dismiss() + } + } else { + showSnackbar('Unable to add account') + Logger.info( + 'Account creation conditions not met, skipping account creation', + { + hasAvalancheKeys: !!keysByNetwork.avalancheKeys, + hasConnectedDeviceId: !!device?.id, + hasSelectedDerivationPath: !!derivationPathType, + isUpdatingWallet + } + ) + resetSetup() + dismiss() + } + }, + [ + device, + wallet, + accounts?.length, + derivationPathType, + isUpdatingWallet, + setIsUpdatingWallet, + createLedgerAccount, + setLedgerAddress, + walletId, + isDeveloperMode, + resetSetup, + dismiss + ] + ) + + return ( + + ) +} diff --git a/packages/core-mobile/app/new/features/ledger/screens/AppConnectionOnboardingScreen.tsx b/packages/core-mobile/app/new/features/ledger/screens/AppConnectionOnboardingScreen.tsx new file mode 100644 index 0000000000..6ee7aa1e0d --- /dev/null +++ b/packages/core-mobile/app/new/features/ledger/screens/AppConnectionOnboardingScreen.tsx @@ -0,0 +1,123 @@ +import React, { useCallback } from 'react' +import Logger from 'utils/Logger' +import { useSelector } from 'react-redux' +import { selectIsDeveloperMode } from 'store/settings/advanced' +import { Alert } from 'react-native' +import LedgerService from 'services/ledger/LedgerService' +import { LedgerKeysByNetwork } from 'services/ledger/types' +import { useRouter } from 'expo-router' +import { useLedgerWallet } from '../hooks/useLedgerWallet' +import { useLedgerSetupContext } from '../contexts/LedgerSetupContext' +import { useSetLedgerAddress } from '../hooks/useSetLedgerAddress' +import AppConnectionScreen from './AppConnectionScreen' + +export const AppConnectionOnboardingScreen = (): JSX.Element => { + const { createLedgerWallet } = useLedgerWallet() + const { push } = useRouter() + const { setLedgerAddress } = useSetLedgerAddress() + const isDeveloperMode = useSelector(selectIsDeveloperMode) + + const { + connectedDeviceId, + connectedDeviceName, + selectedDerivationPath, + disconnectDevice, + isUpdatingWallet, + setIsUpdatingWallet + } = useLedgerSetupContext() + + const handleComplete = useCallback( + async (keys: LedgerKeysByNetwork) => { + const keysByNetwork = isDeveloperMode ? keys.testnet : keys.mainnet + Logger.info('handleComplete called', { + hasAvalancheKeys: !!keysByNetwork.avalancheKeys, + hasConnectedDeviceId: !!connectedDeviceId, + hasSelectedDerivationPath: !!selectedDerivationPath, + isUpdatingWallet, + solanaKeysCount: keysByNetwork.solanaKeys?.length ?? 0 + }) + + // If wallet hasn't been created yet, create it now + if ( + keysByNetwork.avalancheKeys && + connectedDeviceId && + selectedDerivationPath && + !isUpdatingWallet + ) { + Logger.info('All conditions met, creating wallet...') + setIsUpdatingWallet(true) + + try { + const { walletId, accountId } = await createLedgerWallet({ + deviceId: connectedDeviceId, + deviceName: connectedDeviceName, + derivationPathType: selectedDerivationPath, + avalancheKeys: keysByNetwork.avalancheKeys, + solanaKeys: keysByNetwork.solanaKeys + }) + + await setLedgerAddress({ + accountIndex: 0, + walletId, + accountId, + keys + }) + + Logger.info( + 'Wallet created successfully, navigating to complete screen' + ) + // Stop polling since we no longer need app detection + LedgerService.stopAppPolling() + // @ts-ignore TODO: make routes typesafe + push('/accountSettings/ledger/complete') + } catch (error) { + Logger.error('Wallet creation failed', error) + Alert.alert( + 'Wallet Creation Failed', + error instanceof Error + ? error.message + : 'Failed to create Ledger wallet. Please try again.', + [{ text: 'OK' }] + ) + } finally { + setIsUpdatingWallet(false) + } + } else { + Logger.info( + 'Wallet creation conditions not met, skipping wallet creation', + { + hasAvalancheKeys: !!keysByNetwork.avalancheKeys, + hasConnectedDeviceId: !!connectedDeviceId, + hasSelectedDerivationPath: !!selectedDerivationPath, + isUpdatingWallet + } + ) + // @ts-ignore TODO: make routes typesafe + push('/accountSettings/ledger/complete') + } + }, + [ + connectedDeviceId, + selectedDerivationPath, + isUpdatingWallet, + setIsUpdatingWallet, + createLedgerWallet, + connectedDeviceName, + setLedgerAddress, + isDeveloperMode, + push + ] + ) + + return ( + + ) +} diff --git a/packages/core-mobile/app/new/features/ledger/screens/AppConnectionScreen.tsx b/packages/core-mobile/app/new/features/ledger/screens/AppConnectionScreen.tsx index 31b09f6273..fc42c08d8f 100644 --- a/packages/core-mobile/app/new/features/ledger/screens/AppConnectionScreen.tsx +++ b/packages/core-mobile/app/new/features/ledger/screens/AppConnectionScreen.tsx @@ -1,4 +1,10 @@ -import React, { useCallback, useState, useEffect, useMemo } from 'react' +import React, { + useCallback, + useState, + useEffect, + useMemo, + ReactNode +} from 'react' import { useRouter } from 'expo-router' import { Alert, Platform, View } from 'react-native' import { ScrollScreen } from 'common/components/ScrollScreen' @@ -7,192 +13,60 @@ import { AppConnectionStep, LedgerAppConnection } from 'new/features/ledger/components/LedgerAppConnection' -import { useLedgerSetupContext } from 'new/features/ledger/contexts/LedgerSetupContext' import Logger from 'utils/Logger' import LedgerService from 'services/ledger/LedgerService' -import { Button, ButtonType } from '@avalabs/k2-alpine' +import { ActivityIndicator, Button, ButtonType } from '@avalabs/k2-alpine' import { useHeaderHeight } from '@react-navigation/elements' -import { LedgerAppType, LedgerKeys } from 'services/ledger/types' +import { LedgerKeysByNetwork } from 'services/ledger/types' import { selectIsSolanaSupportBlocked } from 'store/posthog' -import { useDispatch, useSelector } from 'react-redux' +import { useSelector } from 'react-redux' import { showSnackbar } from 'common/utils/toast' import { selectIsDeveloperMode } from 'store/settings/advanced' -import { setLedgerAddresses } from 'store/account' -export default function AppConnectionScreen(): JSX.Element { - const { push, back } = useRouter() - const [isCreatingWallet, setIsCreatingWallet] = useState(false) +export default function AppConnectionScreen({ + completeStepTitle, + isUpdatingWallet, + handleComplete, + deviceId, + deviceName = 'Ledger Device', + disconnectDevice, + accountIndex +}: { + completeStepTitle: string + isUpdatingWallet: boolean + deviceId?: string | null + deviceName?: string + disconnectDevice: () => Promise + handleComplete: (keys: LedgerKeysByNetwork) => Promise + accountIndex: number +}): JSX.Element { + const { back } = useRouter() const headerHeight = useHeaderHeight() const isDeveloperMode = useSelector(selectIsDeveloperMode) const isSolanaSupportBlocked = useSelector(selectIsSolanaSupportBlocked) - const dispatch = useDispatch() + + const hasDeviceId = !!deviceId // Local key state - managed only in this component - const [keys, setKeys] = useState({ - solanaKeys: [], - avalancheKeys: undefined, - bitcoinAddress: '', - xpAddress: '' + const [keys, setKeys] = useState({ + mainnet: { + solanaKeys: [], + avalancheKeys: undefined + }, + testnet: { + solanaKeys: [], + avalancheKeys: undefined + } }) const [currentAppConnectionStep, setAppConnectionStep] = useState(AppConnectionStep.AVALANCHE_CONNECT) const [skipSolana, setSkipSolana] = useState(false) - const { - connectedDeviceId, - connectedDeviceName, - selectedDerivationPath, - resetSetup, - disconnectDevice, - createLedgerWallet - } = useLedgerSetupContext() - - const getOppositeKeys = useCallback(async () => { - try { - const avalancheKeys = await LedgerService.getAvalancheKeys( - 0, - !isDeveloperMode - ) - const { bitcoinAddress } = await LedgerService.getBitcoinAndXPAddresses( - 0, - !isDeveloperMode - ) - - return { - addressBTC: bitcoinAddress, - addressAVM: avalancheKeys.addresses.avm, - addressPVM: avalancheKeys.addresses.pvm, - addressCoreEth: avalancheKeys.addresses.coreEth - } - } catch (err) { - Logger.error('Failed to get opposite keys', err) - return { - addressBTC: '', - addressAVM: '', - addressPVM: '', - addressCoreEth: '' - } - } - }, [isDeveloperMode]) - - // eslint-disable-next-line sonarjs/cognitive-complexity - const handleComplete = useCallback(async () => { - Logger.info('handleComplete called', { - hasAvalancheKeys: !!keys.avalancheKeys, - hasConnectedDeviceId: !!connectedDeviceId, - hasSelectedDerivationPath: !!selectedDerivationPath, - isCreatingWallet, - solanaKeysCount: keys.solanaKeys?.length ?? 0 - }) - - // If wallet hasn't been created yet, create it now - if ( - keys.avalancheKeys && - connectedDeviceId && - selectedDerivationPath && - !isCreatingWallet - ) { - Logger.info('All conditions met, creating wallet...') - setIsCreatingWallet(true) - - try { - const { walletId, accountId } = await createLedgerWallet({ - deviceId: connectedDeviceId, - deviceName: connectedDeviceName, - derivationPathType: selectedDerivationPath, - avalancheKeys: keys.avalancheKeys, - solanaKeys: keys.solanaKeys, - bitcoinAddress: keys.bitcoinAddress - }) - - // TODO: implement await/retry logic for any ledger APDU commands - // that could fail due to transport race conditions - setTimeout(async () => { - const oppositeKeys = await getOppositeKeys() - - const mainnet = isDeveloperMode - ? oppositeKeys - : { - addressBTC: keys.bitcoinAddress ?? '', - addressAVM: keys.avalancheKeys?.addresses.avm || '', - addressPVM: keys.avalancheKeys?.addresses.pvm || '', - addressCoreEth: keys.avalancheKeys?.addresses.coreEth || '' - } - - const testnet = isDeveloperMode - ? { - addressBTC: keys.bitcoinAddress ?? '', - addressAVM: keys.avalancheKeys?.addresses.avm || '', - addressPVM: keys.avalancheKeys?.addresses.pvm || '', - addressCoreEth: keys.avalancheKeys?.addresses.coreEth || '' - } - : oppositeKeys - - dispatch( - setLedgerAddresses({ - [accountId]: { - mainnet, - testnet, - walletId, - index: 0, - id: accountId - } - }) - ) - }, 500) - - Logger.info( - 'Wallet created successfully, navigating to complete screen' - ) - // Stop polling since we no longer need app detection - LedgerService.stopAppPolling() - // @ts-ignore TODO: make routes typesafe - push('/accountSettings/ledger/complete') - } catch (error) { - Logger.error('Wallet creation failed', error) - Alert.alert( - 'Wallet Creation Failed', - error instanceof Error - ? error.message - : 'Failed to create Ledger wallet. Please try again.', - [{ text: 'OK' }] - ) - setIsCreatingWallet(false) - } - } else { - Logger.info( - 'Wallet creation conditions not met, skipping wallet creation', - { - hasAvalancheKeys: !!keys.avalancheKeys, - hasConnectedDeviceId: !!connectedDeviceId, - hasSelectedDerivationPath: !!selectedDerivationPath, - isCreatingWallet - } - ) - // @ts-ignore TODO: make routes typesafe - push('/accountSettings/ledger/complete') - } - }, [ - keys.avalancheKeys, - keys.solanaKeys, - keys.bitcoinAddress, - connectedDeviceId, - selectedDerivationPath, - isCreatingWallet, - createLedgerWallet, - connectedDeviceName, - getOppositeKeys, - isDeveloperMode, - dispatch, - push - ]) - const handleCancel = useCallback(async () => { await disconnectDevice() - resetSetup() back() - }, [disconnectDevice, resetSetup, back]) + }, [disconnectDevice, back]) const progressDotsCurrentStep = useMemo(() => { if (isSolanaSupportBlocked) { @@ -232,12 +106,12 @@ export default function AppConnectionScreen(): JSX.Element { return () => { // Only stop polling if we're not in the middle of wallet creation // If wallet creation succeeded, the connection should remain for the wallet to use - if (!isCreatingWallet) { + if (!isUpdatingWallet) { Logger.info('AppConnectionScreen unmounting, stopping app polling') LedgerService.stopAppPolling() } } - }, [isCreatingWallet]) + }, [isUpdatingWallet]) const headerCenterOverlay = useMemo(() => { const paddingTop = Platform.OS === 'ios' ? 15 : 50 @@ -268,37 +142,39 @@ export default function AppConnectionScreen(): JSX.Element { ) }, [headerHeight, isSolanaSupportBlocked, progressDotsCurrentStep]) - // Handler for completing wallet creation - const handleCompleteWallet = useCallback(() => { - Logger.info('User clicked complete wallet button', { - hasAvalancheKeys: !!keys.avalancheKeys, - hasSolanaKeys: keys.solanaKeys && keys.solanaKeys.length > 0, - solanaKeysCount: keys.solanaKeys?.length ?? 0 - }) - handleComplete() - }, [keys, handleComplete]) - const handleConnectAvalanche = useCallback(async () => { try { + if (!deviceId) { + throw new Error('No device ID found') + } + await LedgerService.ensureConnection(deviceId) setAppConnectionStep(AppConnectionStep.AVALANCHE_LOADING) - // Open Avalanche app before getting keys - await LedgerService.openApp(LedgerAppType.AVALANCHE) // Get keys from service const avalancheKeys = await LedgerService.getAvalancheKeys( - 0, + accountIndex, isDeveloperMode ) - const { bitcoinAddress, xpAddress } = - await LedgerService.getBitcoinAndXPAddresses(0, isDeveloperMode) + const oppositeAvalancheKeys = await LedgerService.getAvalancheKeys( + accountIndex, + !isDeveloperMode + ) // Update local state - setKeys(prev => ({ - ...prev, - avalancheKeys, - bitcoinAddress, - xpAddress - })) + setKeys({ + mainnet: { + avalancheKeys: isDeveloperMode + ? oppositeAvalancheKeys + : avalancheKeys, + solanaKeys: [] + }, + testnet: { + avalancheKeys: isDeveloperMode + ? avalancheKeys + : oppositeAvalancheKeys, + solanaKeys: [] + } + }) // Show success toast notification showSnackbar('Avalanche app connected') @@ -317,21 +193,30 @@ export default function AppConnectionScreen(): JSX.Element { [{ text: 'OK' }] ) } - }, [isDeveloperMode, isSolanaSupportBlocked]) + }, [accountIndex, deviceId, isDeveloperMode, isSolanaSupportBlocked]) const handleConnectSolana = useCallback(async () => { try { + if (!deviceId) { + throw new Error('No device ID found') + } + await LedgerService.ensureConnection(deviceId) setAppConnectionStep(AppConnectionStep.SOLANA_LOADING) - // Open Solana app before getting keys - await LedgerService.openApp(LedgerAppType.SOLANA) // Get keys from service - const solanaKeys = await LedgerService.getSolanaKeys(0) + const solanaKeys = await LedgerService.getSolanaKeys(accountIndex) // Update local state setKeys(prev => ({ ...prev, - solanaKeys + mainnet: { + ...prev.mainnet, + solanaKeys + }, + testnet: { + ...prev.testnet, + solanaKeys + } })) // Show success toast notification @@ -348,7 +233,7 @@ export default function AppConnectionScreen(): JSX.Element { [{ text: 'OK' }] ) } - }, []) + }, [accountIndex, deviceId]) const handleSkipSolana = useCallback(() => { // Skip Solana and proceed to complete step @@ -357,11 +242,20 @@ export default function AppConnectionScreen(): JSX.Element { }, [setAppConnectionStep]) const renderFooter = useCallback(() => { - let primary: { text: string; onPress?: () => void; disable?: boolean } = { + let primary: { + text: string | ReactNode + onPress?: () => void + disable?: boolean + } = { text: 'Continue' } let secondary: - | { text: string; onPress?: () => void; type?: ButtonType } + | { + text: string + onPress?: () => void + type?: ButtonType + disable?: boolean + } | undefined switch (currentAppConnectionStep) { @@ -371,7 +265,8 @@ export default function AppConnectionScreen(): JSX.Element { text: 'Continue', onPress: handleConnectAvalanche, disable: - currentAppConnectionStep === AppConnectionStep.AVALANCHE_LOADING + currentAppConnectionStep === AppConnectionStep.AVALANCHE_LOADING || + hasDeviceId === false } break case AppConnectionStep.SOLANA_CONNECT: @@ -379,7 +274,9 @@ export default function AppConnectionScreen(): JSX.Element { primary = { text: 'Continue', onPress: handleConnectSolana, - disable: currentAppConnectionStep === AppConnectionStep.SOLANA_LOADING + disable: + currentAppConnectionStep === AppConnectionStep.SOLANA_LOADING || + hasDeviceId === false } secondary = { text: 'Skip Solana', @@ -389,12 +286,14 @@ export default function AppConnectionScreen(): JSX.Element { break case AppConnectionStep.COMPLETE: primary = { - text: 'Complete setup', - onPress: handleCompleteWallet + text: isUpdatingWallet ? : 'Complete setup', + onPress: () => handleComplete(keys), + disable: isUpdatingWallet } secondary = { - text: 'Cancel setup', - onPress: handleCancel + text: 'Cancel', + onPress: handleCancel, + disable: isUpdatingWallet } break } @@ -411,6 +310,7 @@ export default function AppConnectionScreen(): JSX.Element { {secondary && ( @@ -246,11 +246,10 @@ const LedgerReviewTransactionScreen = ({ const title = useMemo(() => { if (deviceForWallet) { - return `Please review the transaction on your ${deviceForWallet.deviceName}` + return `Please review the transaction on your ${deviceForWallet.name}` } return 'Get your Ledger ready' }, [deviceForWallet]) - const subtitle = useMemo(() => { if (deviceForWallet) { return `Open the ${ledgerAppName} app on your Ledger device in order to continue with this transaction` diff --git a/packages/core-mobile/app/new/features/ledger/screens/SolanaConnectionScreen.tsx b/packages/core-mobile/app/new/features/ledger/screens/SolanaConnectionScreen.tsx index e06c783d49..7b68c4efd9 100644 --- a/packages/core-mobile/app/new/features/ledger/screens/SolanaConnectionScreen.tsx +++ b/packages/core-mobile/app/new/features/ledger/screens/SolanaConnectionScreen.tsx @@ -12,35 +12,35 @@ import LedgerService from 'services/ledger/LedgerService' import { Button } from '@avalabs/k2-alpine' import { PrimaryAccount, selectAccountById } from 'store/account' import { useActiveWallet } from 'common/hooks/useActiveWallet' -import { LedgerDerivationPathType } from 'services/ledger/types' -import { WalletType } from 'services/wallet/types' import { useSelector } from 'react-redux' import { useLedgerWalletMap } from '../store' +import { useLedgerWallet } from '../hooks/useLedgerWallet' export default function SolanaConnectionScreen(): JSX.Element { const { accountId } = useLocalSearchParams<{ accountId: string }>() const account = useSelector(selectAccountById(accountId)) const { back, canGoBack } = useRouter() - const [isUpdatingWallet, setIsUpdatingWallet] = useState(false) const wallet = useActiveWallet() - const { ledgerWalletMap } = useLedgerWalletMap() + const { getLedgerInfoByWalletId } = useLedgerWalletMap() const [currentAppConnectionStep, setAppConnectionStep] = useState< AppConnectionStep.SOLANA_CONNECT | AppConnectionStep.SOLANA_LOADING >(AppConnectionStep.SOLANA_CONNECT) - const deviceForWallet = useMemo(() => { - if (!wallet?.id) return undefined - return ledgerWalletMap[wallet.id] - }, [ledgerWalletMap, wallet?.id]) - const { connectedDeviceId, connectedDeviceName, - updateSolanaForLedgerWallet, + isUpdatingWallet, + setIsUpdatingWallet, connectToDevice, - setConnectedDevice + resetSetup } = useLedgerSetupContext() + const deviceForWallet = useMemo(() => { + return getLedgerInfoByWalletId(wallet?.id)?.device + }, [getLedgerInfoByWalletId, wallet?.id]) + + const { updateSolanaForLedgerWallet } = useLedgerWallet() + // Cleanup: Stop polling when component unmounts (unless wallet update is in progress) useEffect(() => { return () => { @@ -62,18 +62,17 @@ export default function SolanaConnectionScreen(): JSX.Element { setAppConnectionStep(AppConnectionStep.SOLANA_LOADING) // Connect to device if not already connected - if (connectedDeviceId === null && deviceForWallet?.deviceId) { - await connectToDevice(deviceForWallet.deviceId) - setConnectedDevice(deviceForWallet.deviceId, deviceForWallet.deviceName) + if (connectedDeviceId === null && deviceForWallet) { + await connectToDevice(deviceForWallet.id, deviceForWallet.name) } // Get keys from service const solanaKeys = await LedgerService.getSolanaKeys(account.index) - if (solanaKeys.length === 0 || deviceForWallet?.deviceId === undefined) { + if (solanaKeys.length === 0 || deviceForWallet === undefined) { Logger.info('Missing required data for Solana wallet update', { solanaKeysCount: solanaKeys.length, - hasConnectedDeviceId: !!deviceForWallet?.deviceId, + hasConnectedDeviceId: !!deviceForWallet?.id, isUpdatingWallet }) throw new Error('Missing required data for Solana wallet update') @@ -81,7 +80,7 @@ export default function SolanaConnectionScreen(): JSX.Element { if (wallet?.id && wallet?.name) { await updateSolanaForLedgerWallet({ - deviceId: deviceForWallet.deviceId, + deviceId: deviceForWallet.id, walletId: wallet.id, walletName: wallet.name, walletType: wallet.type, @@ -91,7 +90,7 @@ export default function SolanaConnectionScreen(): JSX.Element { } else { Logger.info('Wallet ID or name is missing for Solana wallet update') } - + resetSetup() canGoBack() && back() } catch (err) { Logger.error('Failed to connect to Solana app', err) @@ -106,18 +105,16 @@ export default function SolanaConnectionScreen(): JSX.Element { } }, [ account, - back, + setIsUpdatingWallet, + connectedDeviceId, + deviceForWallet, + wallet, + resetSetup, canGoBack, + back, connectToDevice, - connectedDeviceId, - deviceForWallet?.deviceId, - deviceForWallet?.deviceName, isUpdatingWallet, - setConnectedDevice, - updateSolanaForLedgerWallet, - wallet.id, - wallet.name, - wallet.type + updateSolanaForLedgerWallet ]) const renderFooter = useCallback(() => { @@ -142,13 +139,6 @@ export default function SolanaConnectionScreen(): JSX.Element { flex: 1 }}> + ledgerWalletMap: Record< + walletId, + { + device: Omit + derivationPathType: LedgerDerivationPathType + } + > setLedgerWalletMap: ( walletId: walletId, - deviceId: string, - deviceName: string + device: Omit, + derivationPathType: LedgerDerivationPathType ) => void removeLedgerWallet: (walletId: walletId) => void resetLedgerWalletMap: () => void + getLedgerInfoByWalletId: (walletId?: walletId | null) => { + device: Omit | undefined + derivationPathType: LedgerDerivationPathType | undefined + } } export const ledgerWalletMapStore = create()( persist( (set, get) => ({ ledgerWalletMap: {}, + getLedgerInfoByWalletId: (walletId?: walletId | null) => { + const ledgerWallet = walletId + ? get().ledgerWalletMap[walletId] + : undefined + return { + device: ledgerWallet?.device, + derivationPathType: ledgerWallet?.derivationPathType + } + }, setLedgerWalletMap: ( walletId: walletId, - deviceId: string, - deviceName: string + device: Omit, + derivationPathType: LedgerDerivationPathType ) => set({ ledgerWalletMap: { ...get().ledgerWalletMap, - [walletId]: { deviceId, deviceName } + [walletId]: { device, derivationPathType } } }), removeLedgerWallet: (walletId: walletId) => { - const newLedgerWalletMap = get().ledgerWalletMap + const newLedgerWalletMap = { ...get().ledgerWalletMap } delete newLedgerWalletMap[walletId] set({ ledgerWalletMap: newLedgerWalletMap diff --git a/packages/core-mobile/app/new/features/ledger/utils/index.ts b/packages/core-mobile/app/new/features/ledger/utils/index.ts index a12fc77392..937f884050 100644 --- a/packages/core-mobile/app/new/features/ledger/utils/index.ts +++ b/packages/core-mobile/app/new/features/ledger/utils/index.ts @@ -42,34 +42,24 @@ export const getLedgerAppName = (network?: Network): LedgerAppType => { : LedgerAppType.UNKNOWN } -export const LedgerWalletSecretSchema = z.object({ - deviceId: z.string(), - deviceName: z.string(), - derivationPathSpec: z.nativeEnum(LedgerDerivationPathType), - extendedPublicKeys: z - .object({ - evm: z.string().optional(), - avalanche: z.string().optional() - }) - .optional(), - publicKeys: z.array( - z.object({ - key: z.string(), - derivationPath: z.string(), - curve: z.string() - }) - ), - avalancheKeys: z.object({ - evm: z.string().optional(), - avm: z.string().optional(), - pvm: z.string().optional() - }), - solanaKeys: z.array( - z.object({ - key: z.string(), - derivationPath: z.string(), - curve: z.string() - }) - ), - bitcoinAddress: z.string().optional() -}) +export const LedgerWalletSecretSchema = z + .object({ + deviceId: z.string(), + deviceName: z.string(), + derivationPathSpec: z.nativeEnum(LedgerDerivationPathType), + extendedPublicKeys: z.record( + z.string(), + z.object({ + evm: z.string().optional(), + avalanche: z.string().optional() + }) + ), + publicKeys: z.array( + z.object({ + key: z.string(), + derivationPath: z.string(), + curve: z.string() + }) + ) + }) + .passthrough() diff --git a/packages/core-mobile/app/new/routes/(signedIn)/(modals)/accountSettings/ledger/appConnection.tsx b/packages/core-mobile/app/new/routes/(signedIn)/(modals)/accountSettings/ledger/appConnection.tsx index 7720707500..2f39a6f20a 100644 --- a/packages/core-mobile/app/new/routes/(signedIn)/(modals)/accountSettings/ledger/appConnection.tsx +++ b/packages/core-mobile/app/new/routes/(signedIn)/(modals)/accountSettings/ledger/appConnection.tsx @@ -1,3 +1,3 @@ -import AppConnectionScreen from 'new/features/ledger/screens/AppConnectionScreen' +import { AppConnectionOnboardingScreen } from 'new/features/ledger/screens/AppConnectionOnboardingScreen' -export { AppConnectionScreen as default } +export { AppConnectionOnboardingScreen as default } diff --git a/packages/core-mobile/app/new/routes/(signedIn)/(modals)/addAccountAppConnection/_layout.tsx b/packages/core-mobile/app/new/routes/(signedIn)/(modals)/addAccountAppConnection/_layout.tsx new file mode 100644 index 0000000000..a262f272b2 --- /dev/null +++ b/packages/core-mobile/app/new/routes/(signedIn)/(modals)/addAccountAppConnection/_layout.tsx @@ -0,0 +1,14 @@ +import { Stack } from 'common/components/Stack' +import { + modalFirstScreenOptions, + modalStackNavigatorScreenOptions +} from 'common/consts/screenOptions' +import React from 'react' + +export default function AddAccountAppConnectionLayout(): JSX.Element { + return ( + + + + ) +} diff --git a/packages/core-mobile/app/new/routes/(signedIn)/(modals)/addAccountAppConnection/index.tsx b/packages/core-mobile/app/new/routes/(signedIn)/(modals)/addAccountAppConnection/index.tsx new file mode 100644 index 0000000000..50fd3239e8 --- /dev/null +++ b/packages/core-mobile/app/new/routes/(signedIn)/(modals)/addAccountAppConnection/index.tsx @@ -0,0 +1,3 @@ +import { AppConnectionAddAccountScreen } from 'new/features/ledger/screens/AppConnectionAddAccountScreen' + +export { AppConnectionAddAccountScreen as default } diff --git a/packages/core-mobile/app/new/routes/(signedIn)/_layout.tsx b/packages/core-mobile/app/new/routes/(signedIn)/_layout.tsx index 89b1ae8fd0..9393601600 100644 --- a/packages/core-mobile/app/new/routes/(signedIn)/_layout.tsx +++ b/packages/core-mobile/app/new/routes/(signedIn)/_layout.tsx @@ -281,6 +281,10 @@ export default function WalletLayout(): JSX.Element { name="(modals)/solanaConnection" options={secondaryModalScreensOptions} /> + diff --git a/packages/core-mobile/app/services/account/AccountsService.tsx b/packages/core-mobile/app/services/account/AccountsService.tsx index 8e4d44aee0..cd1849a7c9 100644 --- a/packages/core-mobile/app/services/account/AccountsService.tsx +++ b/packages/core-mobile/app/services/account/AccountsService.tsx @@ -58,11 +58,11 @@ class AccountsService { // For Ledger wallets, preserve existing addresses // since they were retrieved from the device during wallet creation return { - [NetworkVMType.BITCOIN]: addressBTC || account.addressBTC, + [NetworkVMType.BITCOIN]: addressBTC || '', [NetworkVMType.EVM]: account.addressC, - [NetworkVMType.AVM]: addressAVM || account.addressAVM, - [NetworkVMType.PVM]: addressPVM || account.addressPVM, - [NetworkVMType.CoreEth]: addressCoreEth || account.addressCoreEth || '', + [NetworkVMType.AVM]: addressAVM || '', + [NetworkVMType.PVM]: addressPVM || '', + [NetworkVMType.CoreEth]: addressCoreEth || '', [NetworkVMType.SVM]: account.addressSVM ?? '' } as Record } diff --git a/packages/core-mobile/app/services/ledger/LedgerService.ts b/packages/core-mobile/app/services/ledger/LedgerService.ts index 373b0c994f..4d877c0caa 100644 --- a/packages/core-mobile/app/services/ledger/LedgerService.ts +++ b/packages/core-mobile/app/services/ledger/LedgerService.ts @@ -856,13 +856,14 @@ class LedgerService { isTestnet: boolean ): Promise { // Connect to Avalanche app + await this.openApp(LedgerAppType.AVALANCHE) await this.waitForApp(LedgerAppType.AVALANCHE) // Create Avalanche app instance const avalancheApp = new AppAvalanche(this.transport as Transport) const addresses: AddressInfo[] = [] - const networkHrp = isTestnet ? 'fuji' : 'avax' + const networkHrp = isTestnet ? networkIDs.FujiHRP : networkIDs.MainnetHRP try { // Derive addresses for each chain @@ -892,7 +893,7 @@ class LedgerService { await avalancheApp.getAddressAndPubKey( avalancheChainPath, false, - isTestnet ? 'fuji' : 'avax' // hrp for mainnet or testnet, + networkHrp ) const addressWithoutPrefix = stripAddressPrefix( @@ -919,7 +920,7 @@ class LedgerService { const btcPublicKeyResponse = await avalancheApp.getAddressAndPubKey( evmPath, false, - networkHrp // hrp for mainnet or testnet + networkHrp ) const btcAddress = getBtcAddressFromPubKey( Buffer.from(btcPublicKeyResponse.publicKey.toString('hex'), 'hex'), @@ -1005,6 +1006,7 @@ class LedgerService { */ async getSolanaKeys(accountIndex: number): Promise { Logger.info('Getting Solana keys with passive app detection') + await this.openApp(LedgerAppType.SOLANA) await this.waitForApp(LedgerAppType.SOLANA) // Get address directly from Solana app @@ -1057,6 +1059,9 @@ class LedgerService { addresses.find(addr => addr.network === ChainName.AVALANCHE_P)?.address || '' + const btcAddress = + addresses.find(addr => addr.network === ChainName.BITCOIN)?.address || '' + // Derive C-chain bech32 address from EVM address // CoreEth is the EVM address (hex) encoded in bech32 format with C- prefix const hrp = isTestnet ? networkIDs.FujiHRP : networkIDs.MainnetHRP @@ -1092,7 +1097,8 @@ class LedgerService { evm: evmAddress, avm: avmAddress, pvm: pvmAddress, - coreEth: coreEthAddress + coreEth: coreEthAddress, + btc: btcAddress }, xpubs: { evm: evmXpub, @@ -1101,33 +1107,6 @@ class LedgerService { } } - /** - * Get Bitcoin and XP addresses from Avalanche keys - * @param avalancheKeys The avalanche keys to derive addresses from - * @returns Bitcoin and XP addresses - */ - async getBitcoinAndXPAddresses( - accountIndex: number, - isTestnet: boolean - ): Promise<{ - bitcoinAddress: string - xpAddress: string - }> { - const addresses = await this.getAllAddresses(accountIndex, 1, isTestnet) - - // Get addresses for display - const xChainAddress = - addresses.find(addr => addr.network === ChainName.AVALANCHE_X)?.address || - '' - const btcAddress = - addresses.find(addr => addr.network === ChainName.BITCOIN)?.address || '' - - return { - bitcoinAddress: btcAddress, - xpAddress: xChainAddress - } - } - // Helper to build the “open app” APDU for a given app name buildOpenAppApdu(appName: string): Buffer { const cla = 0xe0 diff --git a/packages/core-mobile/app/services/ledger/types.ts b/packages/core-mobile/app/services/ledger/types.ts index f7a4e68ddd..1e099c3e14 100644 --- a/packages/core-mobile/app/services/ledger/types.ts +++ b/packages/core-mobile/app/services/ledger/types.ts @@ -115,6 +115,7 @@ export interface AvalancheKey { avm: string pvm: string coreEth: string // C-chain bech32 format (C-avax1... or C-fuji1...) + btc: string // Bitcoin address } xpubs: { evm: string @@ -125,8 +126,11 @@ export interface AvalancheKey { export interface LedgerKeys { solanaKeys?: PublicKeyInfo[] avalancheKeys?: AvalancheKey - bitcoinAddress?: string - xpAddress?: string +} + +export type LedgerKeysByNetwork = { + mainnet: LedgerKeys + testnet: LedgerKeys } // ============================================================================ @@ -145,10 +149,16 @@ export interface WalletCreationOptions { deviceName?: string derivationPathType: LedgerDerivationPathType accountCount?: number - individualKeys?: PublicKeyInfo[] } -export interface WalletUpdateOptions { +export interface WalletUpdateOptions extends WalletCreationOptions { + walletId: string + walletName: string + walletType: WalletType + accountIndexToUse: number +} + +export interface WalletUpdateSolanaOptions { deviceId: string walletId: string walletName: string