From 7f74be2b96b264c12c7aa88e22fb2548ec331dfd Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:32:23 +0100 Subject: [PATCH 01/20] feat(accounts): add useLedgerDeviceGroups hook [PERA-XXXX] --- .../__tests__/useLedgerDeviceGroups.test.ts | 187 ++++++++++++++++++ packages/accounts/src/hooks/index.ts | 1 + .../src/hooks/useLedgerDeviceGroups.ts | 67 +++++++ 3 files changed, 255 insertions(+) create mode 100644 packages/accounts/src/hooks/__tests__/useLedgerDeviceGroups.test.ts create mode 100644 packages/accounts/src/hooks/useLedgerDeviceGroups.ts diff --git a/packages/accounts/src/hooks/__tests__/useLedgerDeviceGroups.test.ts b/packages/accounts/src/hooks/__tests__/useLedgerDeviceGroups.test.ts new file mode 100644 index 000000000..3a17442ff --- /dev/null +++ b/packages/accounts/src/hooks/__tests__/useLedgerDeviceGroups.test.ts @@ -0,0 +1,187 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import { describe, test, expect, beforeEach, vi } from 'vitest' +import { renderHook } from '@testing-library/react' +import { useLedgerDeviceGroups } from '../useLedgerDeviceGroups' +import type { WalletAccount } from '../../models' + +const mockUseAllAccounts = vi.fn((): WalletAccount[] => []) + +vi.mock('../useAllAccounts', () => ({ + useAllAccounts: () => mockUseAllAccounts(), +})) + +const ledgerDevice1Account0: WalletAccount = { + id: 'ledger-1-0', + address: 'LEDGER_DEV1_ADDR_0', + type: 'hardware', + hardwareDetails: { + manufacturer: 'ledger', + deviceId: 'device-1', + deviceName: 'Cold Wallet', + accountIndex: 0, + }, +} + +const ledgerDevice1Account2: WalletAccount = { + id: 'ledger-1-2', + address: 'LEDGER_DEV1_ADDR_2', + type: 'hardware', + hardwareDetails: { + manufacturer: 'ledger', + deviceId: 'device-1', + deviceName: 'Cold Wallet', + accountIndex: 2, + }, +} + +const ledgerDevice1Account1: WalletAccount = { + id: 'ledger-1-1', + address: 'LEDGER_DEV1_ADDR_1', + type: 'hardware', + hardwareDetails: { + manufacturer: 'ledger', + deviceId: 'device-1', + deviceName: 'Cold Wallet', + accountIndex: 1, + }, +} + +const ledgerDevice2Account0: WalletAccount = { + id: 'ledger-2-0', + address: 'LEDGER_DEV2_ADDR_0', + type: 'hardware', + hardwareDetails: { + manufacturer: 'ledger', + deviceId: 'device-2', + deviceName: 'Backup Ledger', + accountIndex: 0, + }, +} + +const otherHardware: WalletAccount = { + id: 'other-1', + address: 'OTHER_HARDWARE_ADDR', + type: 'hardware', + hardwareDetails: { + manufacturer: 'other' as never, + deviceId: 'other-device', + deviceName: 'Other Device', + accountIndex: 0, + }, +} + +const hdAccount: WalletAccount = { + id: 'hd-1', + address: 'HD_ADDRESS', + type: 'hdWallet', + hdWalletDetails: { + account: 0, + change: 0, + keyIndex: 0, + derivationType: 9, + }, + keyPairId: 'wallet-1', +} + +const watchAccount: WalletAccount = { + id: 'watch-1', + address: 'WATCH_ADDRESS', + type: 'watch', +} + +describe('useLedgerDeviceGroups', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseAllAccounts.mockReturnValue([]) + }) + + test('returns empty groups when no accounts exist', () => { + const { result } = renderHook(() => useLedgerDeviceGroups()) + expect(result.current.ledgerDeviceGroups).toEqual([]) + expect(result.current.hasMultipleLedgerDevices).toBe(false) + }) + + test('returns empty groups when no Ledger accounts exist', () => { + mockUseAllAccounts.mockReturnValue([hdAccount, watchAccount]) + const { result } = renderHook(() => useLedgerDeviceGroups()) + expect(result.current.ledgerDeviceGroups).toEqual([]) + expect(result.current.hasMultipleLedgerDevices).toBe(false) + }) + + test('groups accounts by deviceId and sorts by accountIndex', () => { + mockUseAllAccounts.mockReturnValue([ + ledgerDevice1Account2, + ledgerDevice1Account0, + ledgerDevice1Account1, + ]) + const { result } = renderHook(() => useLedgerDeviceGroups()) + + expect(result.current.ledgerDeviceGroups).toHaveLength(1) + const group = result.current.ledgerDeviceGroups[0] + expect(group.deviceId).toBe('device-1') + expect(group.deviceName).toBe('Cold Wallet') + expect(group.accountCount).toBe(3) + expect(group.accounts.map(a => a.hardwareDetails.accountIndex)).toEqual( + [0, 1, 2], + ) + expect(group.firstAccount).toBe(ledgerDevice1Account0) + expect(result.current.hasMultipleLedgerDevices).toBe(false) + }) + + test('returns separate groups per device', () => { + mockUseAllAccounts.mockReturnValue([ + ledgerDevice1Account0, + ledgerDevice2Account0, + ledgerDevice1Account1, + ]) + const { result } = renderHook(() => useLedgerDeviceGroups()) + + expect(result.current.ledgerDeviceGroups).toHaveLength(2) + expect(result.current.hasMultipleLedgerDevices).toBe(true) + + const dev1 = result.current.ledgerDeviceGroups.find( + g => g.deviceId === 'device-1', + )! + expect(dev1.accountCount).toBe(2) + + const dev2 = result.current.ledgerDeviceGroups.find( + g => g.deviceId === 'device-2', + )! + expect(dev2.accountCount).toBe(1) + expect(dev2.deviceName).toBe('Backup Ledger') + }) + + test('excludes non-Ledger hardware accounts', () => { + mockUseAllAccounts.mockReturnValue([ + ledgerDevice1Account0, + otherHardware, + ]) + const { result } = renderHook(() => useLedgerDeviceGroups()) + + expect(result.current.ledgerDeviceGroups).toHaveLength(1) + expect(result.current.ledgerDeviceGroups[0].deviceId).toBe('device-1') + }) + + test('excludes HD, watch, algo25, multisig accounts', () => { + mockUseAllAccounts.mockReturnValue([ + ledgerDevice1Account0, + hdAccount, + watchAccount, + ]) + const { result } = renderHook(() => useLedgerDeviceGroups()) + + expect(result.current.ledgerDeviceGroups).toHaveLength(1) + expect(result.current.ledgerDeviceGroups[0].accounts).toHaveLength(1) + }) +}) diff --git a/packages/accounts/src/hooks/index.ts b/packages/accounts/src/hooks/index.ts index d062ec074..ffd19d3e1 100644 --- a/packages/accounts/src/hooks/index.ts +++ b/packages/accounts/src/hooks/index.ts @@ -33,6 +33,7 @@ export * from './useAccountDiscovery' export * from './useAccountBalancesInvalidator' export * from './useActiveAccountBalanceInvalidator' export * from './useHDWalletGroups' +export * from './useLedgerDeviceGroups' export * from './useSortedAccounts' export * from './useSortedAssetBalances' export * from './useAllAccountLogicalTypes' diff --git a/packages/accounts/src/hooks/useLedgerDeviceGroups.ts b/packages/accounts/src/hooks/useLedgerDeviceGroups.ts new file mode 100644 index 000000000..03dc9d232 --- /dev/null +++ b/packages/accounts/src/hooks/useLedgerDeviceGroups.ts @@ -0,0 +1,67 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import { useMemo } from 'react' +import { useAllAccounts } from './useAllAccounts' +import { HardwareWalletAccount } from '../models' +import { isLedgerAccount } from '../utils' + +export type LedgerDeviceGroup = { + deviceId: string + deviceName: string + accounts: HardwareWalletAccount[] + firstAccount: HardwareWalletAccount + accountCount: number +} + +type UseLedgerDeviceGroupsResult = { + ledgerDeviceGroups: LedgerDeviceGroup[] + hasMultipleLedgerDevices: boolean +} + +export const useLedgerDeviceGroups = (): UseLedgerDeviceGroupsResult => { + const accounts = useAllAccounts() + + const ledgerDeviceGroups = useMemo(() => { + const ledgerAccounts = accounts.filter(isLedgerAccount) + + const groupMap = new Map() + for (const account of ledgerAccounts) { + const deviceId = account.hardwareDetails.deviceId + const existing = groupMap.get(deviceId) ?? [] + existing.push(account) + groupMap.set(deviceId, existing) + } + + return Array.from(groupMap.entries()).map( + ([deviceId, groupAccounts]): LedgerDeviceGroup => { + const sorted = [...groupAccounts].sort( + (a, b) => + a.hardwareDetails.accountIndex - + b.hardwareDetails.accountIndex, + ) + return { + deviceId, + deviceName: sorted[0].hardwareDetails.deviceName, + accounts: sorted, + firstAccount: sorted[0], + accountCount: sorted.length, + } + }, + ) + }, [accounts]) + + return { + ledgerDeviceGroups, + hasMultipleLedgerDevices: ledgerDeviceGroups.length > 1, + } +} From 121d4ebb324fb2899bedf0dec66f140ec2b3f53a Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:37:20 +0100 Subject: [PATCH 02/20] test(accounts): broaden useLedgerDeviceGroups exclusion test [PERA-XXXX] --- .../__tests__/useLedgerDeviceGroups.test.ts | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/accounts/src/hooks/__tests__/useLedgerDeviceGroups.test.ts b/packages/accounts/src/hooks/__tests__/useLedgerDeviceGroups.test.ts index 3a17442ff..4d0246301 100644 --- a/packages/accounts/src/hooks/__tests__/useLedgerDeviceGroups.test.ts +++ b/packages/accounts/src/hooks/__tests__/useLedgerDeviceGroups.test.ts @@ -74,7 +74,7 @@ const otherHardware: WalletAccount = { address: 'OTHER_HARDWARE_ADDR', type: 'hardware', hardwareDetails: { - manufacturer: 'other' as never, + manufacturer: 'other', deviceId: 'other-device', deviceName: 'Other Device', accountIndex: 0, @@ -100,6 +100,23 @@ const watchAccount: WalletAccount = { type: 'watch', } +const algo25Account: WalletAccount = { + id: 'algo25-1', + address: 'ALGO25_ADDRESS', + type: 'algo25', + keyPairId: 'algo25-key-1', +} + +const multisigAccount: WalletAccount = { + id: 'multisig-1', + address: 'MULTISIG_ADDRESS', + type: 'multisig', + multisigDetails: { + threshold: 2, + addresses: ['A', 'B', 'C'], + }, +} + describe('useLedgerDeviceGroups', () => { beforeEach(() => { vi.clearAllMocks() @@ -178,6 +195,8 @@ describe('useLedgerDeviceGroups', () => { ledgerDevice1Account0, hdAccount, watchAccount, + algo25Account, + multisigAccount, ]) const { result } = renderHook(() => useLedgerDeviceGroups()) From 387f956513c8d50a3db876620de8821c79688929 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:45:09 +0100 Subject: [PATCH 03/20] refactor(accounts): generalize useAccountInfoCard for Ledger structure [PERA-XXXX] --- .../__tests__/useAccountInfoCard.spec.ts | 210 ++++++++++++++++++ .../AccountInfoCard/useAccountInfoCard.ts | 102 ++++++--- 2 files changed, 284 insertions(+), 28 deletions(-) create mode 100644 apps/mobile/src/modules/accounts/components/AccountInfoCard/__tests__/useAccountInfoCard.spec.ts diff --git a/apps/mobile/src/modules/accounts/components/AccountInfoCard/__tests__/useAccountInfoCard.spec.ts b/apps/mobile/src/modules/accounts/components/AccountInfoCard/__tests__/useAccountInfoCard.spec.ts new file mode 100644 index 000000000..c6f089d0e --- /dev/null +++ b/apps/mobile/src/modules/accounts/components/AccountInfoCard/__tests__/useAccountInfoCard.spec.ts @@ -0,0 +1,210 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import { describe, test, expect, beforeEach, vi } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useAccountInfoCard } from '../useAccountInfoCard' +import type { + HDWalletAccount, + HardwareWalletAccount, + WalletAccount, +} from '@perawallet/wallet-core-accounts' + +const mockNavigate = vi.fn() +vi.mock('@routes/navigationRef', () => ({ + navigationRef: { navigate: (...args: unknown[]) => mockNavigate(...args) }, +})) + +vi.mock('@hooks/useLanguage', () => ({ + useLanguage: () => ({ + t: (key: string, params?: Record) => + params?.number != null + ? key.replace('{{number}}', String(params.number)) + : key, + }), +})) + +const mockUseAccountInformationQuery = vi.fn() +const mockUseHDWalletGroups = vi.fn() +const mockUseLedgerDeviceGroups = vi.fn() +const mockUseAccountLogicalType = vi.fn() + +vi.mock('@perawallet/wallet-core-accounts', async importOriginal => { + const actual = + await importOriginal< + typeof import('@perawallet/wallet-core-accounts') + >() + return { + ...actual, + useAccountInformationQuery: (...args: unknown[]) => + mockUseAccountInformationQuery(...args), + useHDWalletGroups: () => mockUseHDWalletGroups(), + useLedgerDeviceGroups: () => mockUseLedgerDeviceGroups(), + useAccountLogicalType: (...args: unknown[]) => + mockUseAccountLogicalType(...args), + } +}) + +vi.mock('@perawallet/wallet-core-blockchain', () => ({ + microAlgosToAlgos: (v: bigint) => ({ toString: () => String(v) }), +})) + +const hdAccount: HDWalletAccount = { + type: 'hdWallet', + address: 'HD_ADDR', + keyPairId: 'key-1', + hdWalletDetails: { + account: 0, + change: 0, + keyIndex: 0, + derivationType: 9, + }, +} + +const ledgerAccount: HardwareWalletAccount = { + type: 'hardware', + address: 'LEDGER_ADDR', + hardwareDetails: { + manufacturer: 'ledger', + deviceId: 'device-abc', + deviceName: 'My Ledger', + accountIndex: 0, + }, +} + +const watchAccount: WalletAccount = { + type: 'watch', + address: 'WATCH_ADDR', +} + +describe('useAccountInfoCard', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseAccountInformationQuery.mockReturnValue({ + data: { minBalance: BigInt(100_000) }, + isLoading: false, + }) + mockUseHDWalletGroups.mockReturnValue({ + hdWalletGroups: [ + { + keyPairId: 'key-1', + accounts: [hdAccount], + firstAccount: hdAccount, + accountCount: 1, + }, + ], + hasMultipleHDWallets: false, + }) + mockUseLedgerDeviceGroups.mockReturnValue({ + ledgerDeviceGroups: [ + { + deviceId: 'device-abc', + deviceName: 'My Ledger', + accounts: [ledgerAccount], + firstAccount: ledgerAccount, + accountCount: 1, + }, + ], + hasMultipleLedgerDevices: false, + }) + mockUseAccountLogicalType.mockImplementation((address: string) => { + if (address === hdAccount.address) return 'HdKey' + if (address === ledgerAccount.address) return 'LedgerBle' + if (address === watchAccount.address) return 'NoAuth' + return null + }) + }) + + test('HD wallet account: showStructure true with wallet label and wallet icon', () => { + const { result } = renderHook(() => + useAccountInfoCard({ account: hdAccount, onClose: vi.fn() }), + ) + expect(result.current.showStructure).toBe(true) + expect(result.current.structureIcon).toBe('wallet') + expect(result.current.structureLabel).toBe('account_info.wallet_label') + expect(result.current.structureAccounts).toEqual([hdAccount]) + }) + + test('Ledger account: showStructure true with deviceName and ledger icon', () => { + const { result } = renderHook(() => + useAccountInfoCard({ account: ledgerAccount, onClose: vi.fn() }), + ) + expect(result.current.showStructure).toBe(true) + expect(result.current.structureIcon).toBe('ledger') + expect(result.current.structureLabel).toBe('My Ledger') + expect(result.current.structureAccounts).toEqual([ledgerAccount]) + }) + + test('Watch account: showStructure false', () => { + const { result } = renderHook(() => + useAccountInfoCard({ account: watchAccount, onClose: vi.fn() }), + ) + expect(result.current.showStructure).toBe(false) + expect(result.current.structureAccounts).toEqual([]) + }) + + test('HD wallet handleScanAddresses navigates to SearchAccounts', () => { + const onClose = vi.fn() + const { result } = renderHook(() => + useAccountInfoCard({ account: hdAccount, onClose }), + ) + act(() => { + result.current.handleScanAddresses() + }) + expect(onClose).toHaveBeenCalled() + expect(mockNavigate).toHaveBeenCalledWith('AddAccount', { + screen: 'SearchAccounts', + params: { account: hdAccount, createIfEmpty: true }, + }) + }) + + test('Ledger handleScanAddresses navigates to LedgerFetchAccounts with deviceId/deviceName', () => { + const onClose = vi.fn() + const { result } = renderHook(() => + useAccountInfoCard({ account: ledgerAccount, onClose }), + ) + act(() => { + result.current.handleScanAddresses() + }) + expect(onClose).toHaveBeenCalled() + expect(mockNavigate).toHaveBeenCalledWith('AddAccount', { + screen: 'LedgerFetchAccounts', + params: { + deviceId: 'device-abc', + deviceName: 'My Ledger', + }, + }) + }) + + test('Watch handleScanAddresses is a no-op', () => { + const onClose = vi.fn() + const { result } = renderHook(() => + useAccountInfoCard({ account: watchAccount, onClose }), + ) + act(() => { + result.current.handleScanAddresses() + }) + expect(mockNavigate).not.toHaveBeenCalled() + expect(onClose).not.toHaveBeenCalled() + }) + + test('handleToggleExpanded toggles isExpanded', () => { + const { result } = renderHook(() => + useAccountInfoCard({ account: hdAccount, onClose: vi.fn() }), + ) + expect(result.current.isExpanded).toBe(false) + act(() => { + result.current.handleToggleExpanded() + }) + expect(result.current.isExpanded).toBe(true) + }) +}) diff --git a/apps/mobile/src/modules/accounts/components/AccountInfoCard/useAccountInfoCard.ts b/apps/mobile/src/modules/accounts/components/AccountInfoCard/useAccountInfoCard.ts index a21b7224c..d1feba56e 100644 --- a/apps/mobile/src/modules/accounts/components/AccountInfoCard/useAccountInfoCard.ts +++ b/apps/mobile/src/modules/accounts/components/AccountInfoCard/useAccountInfoCard.ts @@ -13,11 +13,12 @@ import { useCallback, useMemo, useState } from 'react' import { WalletAccount, - HDWalletAccount, isHDWalletAccount, + isLedgerAccount, isSigningLogicalType, useAccountLogicalType, useHDWalletGroups, + useLedgerDeviceGroups, useAccountInformationQuery, } from '@perawallet/wallet-core-accounts' import { microAlgosToAlgos } from '@perawallet/wallet-core-blockchain' @@ -25,6 +26,7 @@ import { useLanguage } from '@hooks/useLanguage' import { navigationRef } from '@routes/navigationRef' import { Decimal } from 'decimal.js' import type { Nullable } from '@perawallet/wallet-core-shared' +import type { IconName } from '@components/core' type UseAccountInfoCardParams = { account: WalletAccount @@ -37,10 +39,11 @@ type UseAccountInfoCardResult = { accountTypeLabel: string minBalanceAlgos: Nullable isMinBalanceLoading: boolean - isHDWallet: boolean showMinBalance: boolean - walletLabel: string - walletAccounts: HDWalletAccount[] + showStructure: boolean + structureLabel: string + structureIcon: IconName + structureAccounts: WalletAccount[] handleScanAddresses: () => void } @@ -55,10 +58,13 @@ export const useAccountInfoCard = ({ useAccountInformationQuery(account.address) const { hdWalletGroups } = useHDWalletGroups() + const { ledgerDeviceGroups } = useLedgerDeviceGroups() const logicalType = useAccountLogicalType(account.address) ?? 'NoAuth' const isHDWallet = isHDWalletAccount(account) + const isLedger = isLedgerAccount(account) const showMinBalance = isSigningLogicalType(logicalType) + const showStructure = isHDWallet || isLedger const handleToggleExpanded = useCallback(() => { setIsExpanded(prev => !prev) @@ -89,35 +95,74 @@ export const useAccountInfoCard = ({ return microAlgosToAlgos(accountInfo.minBalance) }, [accountInfo?.minBalance]) - const walletGroupIndex = useMemo(() => { - if (!isHDWallet) return 0 + const hdWalletGroupIndex = useMemo(() => { + if (!isHDWallet) return -1 return hdWalletGroups.findIndex( group => group.keyPairId === account.keyPairId, ) }, [isHDWallet, hdWalletGroups, account.keyPairId]) - const walletLabel = useMemo(() => { - return t('account_info.wallet_label', { - number: walletGroupIndex + 1, - }) - }, [walletGroupIndex, t]) + const ledgerDeviceGroup = useMemo(() => { + if (!isLedger) return null + return ( + ledgerDeviceGroups.find( + g => g.deviceId === account.hardwareDetails.deviceId, + ) ?? null + ) + }, [isLedger, ledgerDeviceGroups, account]) + + const structureLabel = useMemo(() => { + if (isHDWallet) { + return t('account_info.wallet_label', { + number: hdWalletGroupIndex + 1, + }) + } + if (isLedger) { + return account.hardwareDetails.deviceName + } + return '' + }, [isHDWallet, isLedger, account, hdWalletGroupIndex, t]) - const walletAccounts = useMemo(() => { - if (!isHDWallet || walletGroupIndex < 0) return [] - return hdWalletGroups[walletGroupIndex].accounts - }, [isHDWallet, walletGroupIndex, hdWalletGroups]) + const structureIcon: IconName = isLedger ? 'ledger' : 'wallet' + + const structureAccounts = useMemo(() => { + if (isHDWallet && hdWalletGroupIndex >= 0) { + return hdWalletGroups[hdWalletGroupIndex].accounts + } + if (isLedger && ledgerDeviceGroup) { + return ledgerDeviceGroup.accounts + } + return [] + }, [ + isHDWallet, + hdWalletGroupIndex, + hdWalletGroups, + isLedger, + ledgerDeviceGroup, + ]) const handleScanAddresses = useCallback(() => { - if (!isHDWalletAccount(account)) return - - onClose() - navigationRef.navigate('AddAccount', { - screen: 'SearchAccounts', - params: { - account, - createIfEmpty: true, - }, - }) + if (isHDWalletAccount(account)) { + onClose() + navigationRef.navigate('AddAccount', { + screen: 'SearchAccounts', + params: { + account, + createIfEmpty: true, + }, + }) + return + } + if (isLedgerAccount(account)) { + onClose() + navigationRef.navigate('AddAccount', { + screen: 'LedgerFetchAccounts', + params: { + deviceId: account.hardwareDetails.deviceId, + deviceName: account.hardwareDetails.deviceName, + }, + }) + } }, [account, onClose]) return { @@ -126,10 +171,11 @@ export const useAccountInfoCard = ({ accountTypeLabel, minBalanceAlgos, isMinBalanceLoading, - isHDWallet, showMinBalance, - walletLabel, - walletAccounts, + showStructure, + structureLabel, + structureIcon, + structureAccounts, handleScanAddresses, } } From fa9cc06cd26a8eaa5cec64f3105b7c20abbf7675 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:56:28 +0100 Subject: [PATCH 04/20] refactor(accounts): tighten useAccountInfoCard memo dependencies [PERA-XXXX] --- .../accounts/components/AccountInfoCard/useAccountInfoCard.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/modules/accounts/components/AccountInfoCard/useAccountInfoCard.ts b/apps/mobile/src/modules/accounts/components/AccountInfoCard/useAccountInfoCard.ts index d1feba56e..2f5d26f7d 100644 --- a/apps/mobile/src/modules/accounts/components/AccountInfoCard/useAccountInfoCard.ts +++ b/apps/mobile/src/modules/accounts/components/AccountInfoCard/useAccountInfoCard.ts @@ -109,7 +109,7 @@ export const useAccountInfoCard = ({ g => g.deviceId === account.hardwareDetails.deviceId, ) ?? null ) - }, [isLedger, ledgerDeviceGroups, account]) + }, [isLedger, ledgerDeviceGroups, account.hardwareDetails?.deviceId]) const structureLabel = useMemo(() => { if (isHDWallet) { @@ -121,7 +121,7 @@ export const useAccountInfoCard = ({ return account.hardwareDetails.deviceName } return '' - }, [isHDWallet, isLedger, account, hdWalletGroupIndex, t]) + }, [isHDWallet, isLedger, account.hardwareDetails?.deviceName, hdWalletGroupIndex, t]) const structureIcon: IconName = isLedger ? 'ledger' : 'wallet' From 81a91b3f5a65e0809cc59393563e056911d3f297 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:56:42 +0100 Subject: [PATCH 05/20] chore: format and update copyright header --- .../components/AccountInfoCard/useAccountInfoCard.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/modules/accounts/components/AccountInfoCard/useAccountInfoCard.ts b/apps/mobile/src/modules/accounts/components/AccountInfoCard/useAccountInfoCard.ts index 2f5d26f7d..71292030d 100644 --- a/apps/mobile/src/modules/accounts/components/AccountInfoCard/useAccountInfoCard.ts +++ b/apps/mobile/src/modules/accounts/components/AccountInfoCard/useAccountInfoCard.ts @@ -121,7 +121,13 @@ export const useAccountInfoCard = ({ return account.hardwareDetails.deviceName } return '' - }, [isHDWallet, isLedger, account.hardwareDetails?.deviceName, hdWalletGroupIndex, t]) + }, [ + isHDWallet, + isLedger, + account.hardwareDetails?.deviceName, + hdWalletGroupIndex, + t, + ]) const structureIcon: IconName = isLedger ? 'ledger' : 'wallet' From b17060e761c78017f655c9e83e622cc596cc7950 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:59:50 +0100 Subject: [PATCH 06/20] refactor(accounts): tighten useAccountInfoCard memo dependencies [PERA-XXXX] --- .../AccountInfoCard/useAccountInfoCard.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/modules/accounts/components/AccountInfoCard/useAccountInfoCard.ts b/apps/mobile/src/modules/accounts/components/AccountInfoCard/useAccountInfoCard.ts index 71292030d..409e89bf4 100644 --- a/apps/mobile/src/modules/accounts/components/AccountInfoCard/useAccountInfoCard.ts +++ b/apps/mobile/src/modules/accounts/components/AccountInfoCard/useAccountInfoCard.ts @@ -13,6 +13,7 @@ import { useCallback, useMemo, useState } from 'react' import { WalletAccount, + HardwareWalletAccount, isHDWalletAccount, isLedgerAccount, isSigningLogicalType, @@ -102,14 +103,18 @@ export const useAccountInfoCard = ({ ) }, [isHDWallet, hdWalletGroups, account.keyPairId]) + const ledgerDeviceId = isLedger ? (account as HardwareWalletAccount).hardwareDetails.deviceId : null + const ledgerDeviceGroup = useMemo(() => { if (!isLedger) return null return ( ledgerDeviceGroups.find( - g => g.deviceId === account.hardwareDetails.deviceId, + g => g.deviceId === ledgerDeviceId, ) ?? null ) - }, [isLedger, ledgerDeviceGroups, account.hardwareDetails?.deviceId]) + }, [isLedger, ledgerDeviceGroups, ledgerDeviceId]) + + const ledgerDeviceName = isLedger ? (account as HardwareWalletAccount).hardwareDetails.deviceName : null const structureLabel = useMemo(() => { if (isHDWallet) { @@ -118,16 +123,10 @@ export const useAccountInfoCard = ({ }) } if (isLedger) { - return account.hardwareDetails.deviceName + return ledgerDeviceName ?? '' } return '' - }, [ - isHDWallet, - isLedger, - account.hardwareDetails?.deviceName, - hdWalletGroupIndex, - t, - ]) + }, [isHDWallet, isLedger, ledgerDeviceName, hdWalletGroupIndex, t]) const structureIcon: IconName = isLedger ? 'ledger' : 'wallet' From 574802b5594a3d91191e1ebc682c9b7ddd8c73e9 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:00:05 +0100 Subject: [PATCH 07/20] fix(accounts): update AccountInfoCard to use correct hook properties [PERA-XXXX] --- .../components/AccountInfoCard/AccountInfoCard.tsx | 14 +++++++------- .../AccountInfoCard/WalletStructureTree.tsx | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/mobile/src/modules/accounts/components/AccountInfoCard/AccountInfoCard.tsx b/apps/mobile/src/modules/accounts/components/AccountInfoCard/AccountInfoCard.tsx index 8a819ee8a..5c2f2154f 100644 --- a/apps/mobile/src/modules/accounts/components/AccountInfoCard/AccountInfoCard.tsx +++ b/apps/mobile/src/modules/accounts/components/AccountInfoCard/AccountInfoCard.tsx @@ -45,10 +45,10 @@ export const AccountInfoCard = ({ account, onClose }: AccountInfoCardProps) => { accountTypeLabel, minBalanceAlgos, isMinBalanceLoading, - isHDWallet, showMinBalance, - walletLabel, - walletAccounts, + showStructure, + structureLabel, + structureAccounts, handleScanAddresses, } = useAccountInfoCard({ account, onClose }) @@ -118,13 +118,13 @@ export const AccountInfoCard = ({ account, onClose }: AccountInfoCardProps) => { )} - {/* Wallet structure (HD wallets only) */} - {isHDWallet && ( + {/* Wallet structure (HD wallets and Ledger devices) */} + {showStructure && ( <> diff --git a/apps/mobile/src/modules/accounts/components/AccountInfoCard/WalletStructureTree.tsx b/apps/mobile/src/modules/accounts/components/AccountInfoCard/WalletStructureTree.tsx index 56a9284c1..56d7cfe50 100644 --- a/apps/mobile/src/modules/accounts/components/AccountInfoCard/WalletStructureTree.tsx +++ b/apps/mobile/src/modules/accounts/components/AccountInfoCard/WalletStructureTree.tsx @@ -18,14 +18,14 @@ import { PWView, } from '@components/core' import { CopyableText } from '@components/CopyableText' -import { HDWalletAccount } from '@perawallet/wallet-core-accounts' +import { WalletAccount } from '@perawallet/wallet-core-accounts' import { truncateAlgorandAddress } from '@perawallet/wallet-core-shared' import { useLanguage } from '@hooks/useLanguage' import { useStyles } from './styles' import { AccountIcon } from '../AccountIcon' type WalletStructureTreeProps = { walletLabel: string - accounts: HDWalletAccount[] + accounts: WalletAccount[] onScanAddresses: () => void } From 75605a0280266a21ffee7d6b9c9da79cc3fb73bf Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:00:57 +0100 Subject: [PATCH 08/20] chore: format and update copyright header --- .../components/AccountInfoCard/useAccountInfoCard.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/modules/accounts/components/AccountInfoCard/useAccountInfoCard.ts b/apps/mobile/src/modules/accounts/components/AccountInfoCard/useAccountInfoCard.ts index 409e89bf4..9aa720049 100644 --- a/apps/mobile/src/modules/accounts/components/AccountInfoCard/useAccountInfoCard.ts +++ b/apps/mobile/src/modules/accounts/components/AccountInfoCard/useAccountInfoCard.ts @@ -103,18 +103,20 @@ export const useAccountInfoCard = ({ ) }, [isHDWallet, hdWalletGroups, account.keyPairId]) - const ledgerDeviceId = isLedger ? (account as HardwareWalletAccount).hardwareDetails.deviceId : null + const ledgerDeviceId = isLedger + ? (account as HardwareWalletAccount).hardwareDetails.deviceId + : null const ledgerDeviceGroup = useMemo(() => { if (!isLedger) return null return ( - ledgerDeviceGroups.find( - g => g.deviceId === ledgerDeviceId, - ) ?? null + ledgerDeviceGroups.find(g => g.deviceId === ledgerDeviceId) ?? null ) }, [isLedger, ledgerDeviceGroups, ledgerDeviceId]) - const ledgerDeviceName = isLedger ? (account as HardwareWalletAccount).hardwareDetails.deviceName : null + const ledgerDeviceName = isLedger + ? (account as HardwareWalletAccount).hardwareDetails.deviceName + : null const structureLabel = useMemo(() => { if (isHDWallet) { From 68089da8ae24f13012c798acdb8165d46a85ad82 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:06:48 +0100 Subject: [PATCH 09/20] feat(accounts): show Ledger device tree in account more menu [PERA-XXXX] Rename WalletStructureTree to AccountStructureTree, generalize its props (label/icon/accounts), thread structureIcon from useAccountInfoCard through AccountInfoCard into the tree component so Ledger accounts render the correct icon. Adds three Ledger-specific tests to the AccountInfoCard spec. --- .../AccountInfoCard/AccountInfoCard.tsx | 8 ++- ...ctureTree.tsx => AccountStructureTree.tsx} | 18 +++--- .../__tests__/AccountInfoCard.spec.tsx | 62 +++++++++++++++++++ 3 files changed, 78 insertions(+), 10 deletions(-) rename apps/mobile/src/modules/accounts/components/AccountInfoCard/{WalletStructureTree.tsx => AccountStructureTree.tsx} (92%) diff --git a/apps/mobile/src/modules/accounts/components/AccountInfoCard/AccountInfoCard.tsx b/apps/mobile/src/modules/accounts/components/AccountInfoCard/AccountInfoCard.tsx index 5c2f2154f..8785d8010 100644 --- a/apps/mobile/src/modules/accounts/components/AccountInfoCard/AccountInfoCard.tsx +++ b/apps/mobile/src/modules/accounts/components/AccountInfoCard/AccountInfoCard.tsx @@ -25,7 +25,7 @@ import { useAccountInfoCard } from './useAccountInfoCard' import { AccountIcon } from '../AccountIcon' import { CurrencyDisplay } from '@components/CurrencyDisplay' import { ExpandablePanel } from '@components/ExpandablePanel' -import { WalletStructureTree } from './WalletStructureTree' +import { AccountStructureTree } from './AccountStructureTree' import { InfoButton } from '@components/InfoButton' import { AccountTypeInfoContent } from './AccountTypeInfoContent' import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated' @@ -48,6 +48,7 @@ export const AccountInfoCard = ({ account, onClose }: AccountInfoCardProps) => { showMinBalance, showStructure, structureLabel, + structureIcon, structureAccounts, handleScanAddresses, } = useAccountInfoCard({ account, onClose }) @@ -122,8 +123,9 @@ export const AccountInfoCard = ({ account, onClose }: AccountInfoCardProps) => { {showStructure && ( <> - diff --git a/apps/mobile/src/modules/accounts/components/AccountInfoCard/WalletStructureTree.tsx b/apps/mobile/src/modules/accounts/components/AccountInfoCard/AccountStructureTree.tsx similarity index 92% rename from apps/mobile/src/modules/accounts/components/AccountInfoCard/WalletStructureTree.tsx rename to apps/mobile/src/modules/accounts/components/AccountInfoCard/AccountStructureTree.tsx index 56d7cfe50..72e637306 100644 --- a/apps/mobile/src/modules/accounts/components/AccountInfoCard/WalletStructureTree.tsx +++ b/apps/mobile/src/modules/accounts/components/AccountInfoCard/AccountStructureTree.tsx @@ -11,6 +11,7 @@ */ import { + IconName, PWIcon, PWRoundIcon, PWText, @@ -23,17 +24,20 @@ import { truncateAlgorandAddress } from '@perawallet/wallet-core-shared' import { useLanguage } from '@hooks/useLanguage' import { useStyles } from './styles' import { AccountIcon } from '../AccountIcon' -type WalletStructureTreeProps = { - walletLabel: string + +type AccountStructureTreeProps = { + label: string + icon: IconName accounts: WalletAccount[] onScanAddresses: () => void } -export const WalletStructureTree = ({ - walletLabel, +export const AccountStructureTree = ({ + label, + icon, accounts, onScanAddresses, -}: WalletStructureTreeProps) => { +}: AccountStructureTreeProps) => { const styles = useStyles() const { t } = useLanguage() @@ -41,11 +45,11 @@ export const WalletStructureTree = ({ - {walletLabel} + {label} {accounts.map(account => ( diff --git a/apps/mobile/src/modules/accounts/components/AccountInfoCard/__tests__/AccountInfoCard.spec.tsx b/apps/mobile/src/modules/accounts/components/AccountInfoCard/__tests__/AccountInfoCard.spec.tsx index f2bd9ce13..9a8278448 100644 --- a/apps/mobile/src/modules/accounts/components/AccountInfoCard/__tests__/AccountInfoCard.spec.tsx +++ b/apps/mobile/src/modules/accounts/components/AccountInfoCard/__tests__/AccountInfoCard.spec.tsx @@ -16,6 +16,7 @@ import { render, screen, fireEvent } from '@test-utils/render' import { AccountInfoCard } from '../AccountInfoCard' import { HDWalletAccount, + HardwareWalletAccount, WalletAccount, } from '@perawallet/wallet-core-accounts' import { Decimal } from 'decimal.js' @@ -123,6 +124,7 @@ vi.mock('@perawallet/wallet-core-blockchain', () => ({ const mockUseAccountInformationQuery = vi.fn() const mockUseHDWalletGroups = vi.fn() +const mockUseLedgerDeviceGroups = vi.fn() const mockUseAccountLogicalType = vi.fn() vi.mock('@perawallet/wallet-core-accounts', async importOriginal => { const actual = @@ -134,6 +136,7 @@ vi.mock('@perawallet/wallet-core-accounts', async importOriginal => { useAccountInformationQuery: (...args: unknown[]) => mockUseAccountInformationQuery(...args), useHDWalletGroups: () => mockUseHDWalletGroups(), + useLedgerDeviceGroups: () => mockUseLedgerDeviceGroups(), useAccountLogicalType: (...args: unknown[]) => mockUseAccountLogicalType(...args), } @@ -167,6 +170,18 @@ const watchAccount: WalletAccount = { address: 'WATCHADDR1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ', } +const ledgerAccount: HardwareWalletAccount = { + type: 'hardware', + address: 'LEDGERADDR1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ', + hardwareDetails: { + manufacturer: 'ledger', + deviceId: 'device-xyz', + deviceName: 'My Ledger', + accountIndex: 0, + }, + name: 'Cold Wallet', +} + describe('AccountInfoCard', () => { beforeEach(() => { mockUseAccountInformationQuery.mockReturnValue({ @@ -184,8 +199,21 @@ describe('AccountInfoCard', () => { ], hasMultipleHDWallets: false, }) + mockUseLedgerDeviceGroups.mockReturnValue({ + ledgerDeviceGroups: [ + { + deviceId: 'device-xyz', + deviceName: 'My Ledger', + accounts: [ledgerAccount], + firstAccount: ledgerAccount, + accountCount: 1, + }, + ], + hasMultipleLedgerDevices: false, + }) mockUseAccountLogicalType.mockImplementation((address: string) => { if (address === hdAccount.address) return 'HdKey' + if (address === ledgerAccount.address) return 'LedgerBle' if (address === watchAccount.address) return 'NoAuth' return null }) @@ -306,4 +334,38 @@ describe('AccountInfoCard', () => { expect(screen.queryByText('min_balance_info.description')).toBeNull() }) + + it('shows wallet structure toggle for Ledger accounts', () => { + render( + , + ) + expect( + screen.getByText('account_info.see_wallet_structure'), + ).toBeTruthy() + }) + + it('renders Ledger device name in expanded structure', () => { + render( + , + ) + fireEvent.click(screen.getByText('account_info.see_wallet_structure')) + expect(screen.getByTestId('expandable-panel')).toBeTruthy() + expect(screen.getByText('My Ledger')).toBeTruthy() + }) + + it('renders account type label for Ledger account', () => { + render( + , + ) + expect(screen.getByText('account_info.type_ledger')).toBeTruthy() + }) }) From 372a14290e5e174839a87539cc49f6eef575f15b Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:15:29 +0100 Subject: [PATCH 10/20] refactor(accounts): drop HardwareWalletAccount casts in useAccountInfoCard [PERA-XXXX] --- .../AccountInfoCard/useAccountInfoCard.ts | 23 +++++++------------ 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/apps/mobile/src/modules/accounts/components/AccountInfoCard/useAccountInfoCard.ts b/apps/mobile/src/modules/accounts/components/AccountInfoCard/useAccountInfoCard.ts index 9aa720049..62b74ef40 100644 --- a/apps/mobile/src/modules/accounts/components/AccountInfoCard/useAccountInfoCard.ts +++ b/apps/mobile/src/modules/accounts/components/AccountInfoCard/useAccountInfoCard.ts @@ -13,7 +13,6 @@ import { useCallback, useMemo, useState } from 'react' import { WalletAccount, - HardwareWalletAccount, isHDWalletAccount, isLedgerAccount, isSigningLogicalType, @@ -103,20 +102,14 @@ export const useAccountInfoCard = ({ ) }, [isHDWallet, hdWalletGroups, account.keyPairId]) - const ledgerDeviceId = isLedger - ? (account as HardwareWalletAccount).hardwareDetails.deviceId - : null - const ledgerDeviceGroup = useMemo(() => { - if (!isLedger) return null + if (!isLedgerAccount(account)) return null return ( - ledgerDeviceGroups.find(g => g.deviceId === ledgerDeviceId) ?? null + ledgerDeviceGroups.find( + g => g.deviceId === account.hardwareDetails.deviceId, + ) ?? null ) - }, [isLedger, ledgerDeviceGroups, ledgerDeviceId]) - - const ledgerDeviceName = isLedger - ? (account as HardwareWalletAccount).hardwareDetails.deviceName - : null + }, [account, ledgerDeviceGroups]) const structureLabel = useMemo(() => { if (isHDWallet) { @@ -124,11 +117,11 @@ export const useAccountInfoCard = ({ number: hdWalletGroupIndex + 1, }) } - if (isLedger) { - return ledgerDeviceName ?? '' + if (isLedgerAccount(account)) { + return account.hardwareDetails.deviceName } return '' - }, [isHDWallet, isLedger, ledgerDeviceName, hdWalletGroupIndex, t]) + }, [isHDWallet, account, hdWalletGroupIndex, t]) const structureIcon: IconName = isLedger ? 'ledger' : 'wallet' From 98b2bfd371f3d03c566378177f7800d8d1f51e12 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:56:00 +0100 Subject: [PATCH 11/20] fix: fixing them provider rendering --- apps/mobile/src/App.tsx | 31 ++++++++++++------- .../RootComponent/RootComponent.tsx | 23 +++++--------- .../src/components/core/PWText/PWText.tsx | 4 --- apps/mobile/src/theme/theme.ts | 9 +----- 4 files changed, 28 insertions(+), 39 deletions(-) diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 3cffaddd6..5e83cd404 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -42,7 +42,10 @@ import { usePeraProvider, } from '@perawallet/wallet-extension-provider' import { SafeAreaProvider } from 'react-native-safe-area-context' +import { ThemeProvider } from '@rneui/themed' import { RootComponent } from '@components/RootComponent' +import { getTheme } from '@theme/theme' +import { useIsDarkMode } from '@hooks/useIsDarkMode' import * as SplashScreen from 'expo-splash-screen' // Keep the splash screen visible while we fetch resources @@ -73,6 +76,8 @@ const AppContent = () => { const [fcmToken, setFcmToken] = useState>(null) const { t } = useLanguage() const provider = usePeraProvider() + const isDarkMode = useIsDarkMode() + const theme = getTheme(isDarkMode ? 'dark' : 'light') useEffect(() => { logger.setErrorReporter( @@ -127,18 +132,20 @@ const AppContent = () => { }, [bootstrapped, provider]) return ( - - {!bootstrapped && {t('common.loading.label')}} - {bootstrapped && persister && ( - - - - - - - - )} - + + + {!bootstrapped && {t('common.loading.label')}} + {bootstrapped && persister && ( + + + + + + + + )} + + ) } diff --git a/apps/mobile/src/components/RootComponent/RootComponent.tsx b/apps/mobile/src/components/RootComponent/RootComponent.tsx index eeec48690..f91e7e5c6 100644 --- a/apps/mobile/src/components/RootComponent/RootComponent.tsx +++ b/apps/mobile/src/components/RootComponent/RootComponent.tsx @@ -16,14 +16,11 @@ import { AppState } from 'react-native' import { GestureHandlerRootView } from 'react-native-gesture-handler' import { BottomSheetModalProvider } from '@gorhom/bottom-sheet' import { MainRoutes } from '@routes/index' -import { getTheme } from '@theme/theme' -import { ThemeProvider } from '@rneui/themed' import { useStyles } from './styles' import { PWText, PWView } from '@components/core' import { useSafeAreaInsets } from 'react-native-safe-area-context' import ErrorBoundary from 'react-native-error-boundary' import { useToast } from '@hooks/useToast' -import { useIsDarkMode } from '@hooks/useIsDarkMode' import { useDevice } from '@perawallet/wallet-core-device' import { useNetwork } from '@perawallet/wallet-core-blockchain' import { useAllAccounts } from '@perawallet/wallet-core-accounts' @@ -101,8 +98,6 @@ const RootContentContainer = ({ fcmToken }: RootComponentProps) => { } export const RootComponent = ({ fcmToken }: RootComponentProps) => { - const isDarkMode = useIsDarkMode() - const theme = getTheme(isDarkMode ? 'dark' : 'light') const { network } = useNetwork() const { registerDevice } = useDevice() const accounts = useAllAccounts() @@ -177,15 +172,13 @@ export const RootComponent = ({ fcmToken }: RootComponentProps) => { }, [appStatePlatform, accounts, runSyncAction]) return ( - - - - - - - - - - + + + + + + + + ) } diff --git a/apps/mobile/src/components/core/PWText/PWText.tsx b/apps/mobile/src/components/core/PWText/PWText.tsx index 28b45eb16..753299591 100644 --- a/apps/mobile/src/components/core/PWText/PWText.tsx +++ b/apps/mobile/src/components/core/PWText/PWText.tsx @@ -48,10 +48,6 @@ export const PWText = ({ return ( ({ ...DefaultTheme, @@ -599,12 +599,5 @@ export const getTheme = (mode: 'light' | 'dark' = 'light') => }, thumbColor: theme.colors.textWhite, }), - Text: (_, theme) => ({ - h1Style: getTypography(theme, 'h1'), - h2Style: getTypography(theme, 'h2'), - h3Style: getTypography(theme, 'h3'), - h4Style: getTypography(theme, 'h4'), - style: getTypography(theme, 'body'), - }), }, }) From f1b71357bfcab43df08c79d42397765ffc3fce13 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:49:38 +0100 Subject: [PATCH 12/20] feat(ledger): add find_another_wallet i18n key [PERA-XXXX] --- apps/mobile/src/i18n/locales/en.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/i18n/locales/en.json b/apps/mobile/src/i18n/locales/en.json index 4e69502d2..8744d4766 100644 --- a/apps/mobile/src/i18n/locales/en.json +++ b/apps/mobile/src/i18n/locales/en.json @@ -1584,7 +1584,8 @@ "select_all": "Select all", "already_imported": "Already imported", "continue": "Continue", - "no_new_accounts": "All accounts on this device are already imported." + "no_new_accounts": "All accounts on this device are already imported.", + "find_another_wallet": "Find another wallet" }, "verify": { "title": "Verify on Ledger", From b36125331acef8b7de1a08cbfb442ff32754c5fe Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:00:15 +0100 Subject: [PATCH 13/20] feat(ledger): add FindAnotherWalletRow component [PERA-XXXX] --- .../FindAnotherWalletRow.tsx | 63 +++++++++++++++++++ .../__tests__/FindAnotherWalletRow.spec.tsx | 62 ++++++++++++++++++ .../FindAnotherWalletRow/index.ts | 14 +++++ .../FindAnotherWalletRow/styles.ts | 34 ++++++++++ 4 files changed, 173 insertions(+) create mode 100644 apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/FindAnotherWalletRow/FindAnotherWalletRow.tsx create mode 100644 apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/FindAnotherWalletRow/__tests__/FindAnotherWalletRow.spec.tsx create mode 100644 apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/FindAnotherWalletRow/index.ts create mode 100644 apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/FindAnotherWalletRow/styles.ts diff --git a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/FindAnotherWalletRow/FindAnotherWalletRow.tsx b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/FindAnotherWalletRow/FindAnotherWalletRow.tsx new file mode 100644 index 000000000..2a4c4ae7d --- /dev/null +++ b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/FindAnotherWalletRow/FindAnotherWalletRow.tsx @@ -0,0 +1,63 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import { ActivityIndicator } from 'react-native' +import { PWIcon, PWText, PWTouchableOpacity, PWView } from '@components/core' +import { useStyles } from './styles' + +export type FindAnotherWalletRowProps = { + onPress: () => void + isLoading: boolean + label: string + testID?: string +} + +export const FindAnotherWalletRow = ({ + onPress, + isLoading, + label, + testID, +}: FindAnotherWalletRowProps) => { + const styles = useStyles({ isLoading }) + + return ( + + + {isLoading ? ( + + ) : ( + + )} + + + {label} + + + ) +} diff --git a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/FindAnotherWalletRow/__tests__/FindAnotherWalletRow.spec.tsx b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/FindAnotherWalletRow/__tests__/FindAnotherWalletRow.spec.tsx new file mode 100644 index 000000000..7ff8fafff --- /dev/null +++ b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/FindAnotherWalletRow/__tests__/FindAnotherWalletRow.spec.tsx @@ -0,0 +1,62 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import { describe, it, expect, vi } from 'vitest' +import { render, fireEvent, screen } from '@test-utils/render' +import { FindAnotherWalletRow } from '../FindAnotherWalletRow' + +describe('FindAnotherWalletRow', () => { + it('calls onPress when tapped in idle state', () => { + const onPress = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByText('Find another wallet')) + + expect(onPress).toHaveBeenCalledTimes(1) + }) + + it('does not call onPress while loading', () => { + const onPress = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByText('Find another wallet')) + + expect(onPress).not.toHaveBeenCalled() + }) + + it('renders an ActivityIndicator when loading', () => { + render( + , + ) + + expect( + screen.getByTestId('find-another-row-spinner'), + ).toBeTruthy() + }) +}) diff --git a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/FindAnotherWalletRow/index.ts b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/FindAnotherWalletRow/index.ts new file mode 100644 index 000000000..3c2028213 --- /dev/null +++ b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/FindAnotherWalletRow/index.ts @@ -0,0 +1,14 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +export { FindAnotherWalletRow } from './FindAnotherWalletRow' +export type { FindAnotherWalletRowProps } from './FindAnotherWalletRow' diff --git a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/FindAnotherWalletRow/styles.ts b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/FindAnotherWalletRow/styles.ts new file mode 100644 index 000000000..13f437349 --- /dev/null +++ b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/FindAnotherWalletRow/styles.ts @@ -0,0 +1,34 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import { makeStyles } from '@rneui/themed' + +type StyleProps = { + isLoading: boolean +} + +export const useStyles = makeStyles((theme, { isLoading }: StyleProps) => ({ + container: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: theme.spacing.md, + opacity: isLoading ? 0.6 : 1, + }, + iconContainer: { + marginRight: theme.spacing.md, + alignItems: 'center', + justifyContent: 'center', + }, + label: { + color: theme.colors.linkPrimary, + }, +})) From abd1facaffa8a3ef7b494c9d1c79e674594fc0cd Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:08:23 +0100 Subject: [PATCH 14/20] test(ledger): add failing tests for find-another-wallet hook [PERA-XXXX] --- .../useLedgerSelectAccountsScreen.spec.ts | 247 ++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/__tests__/useLedgerSelectAccountsScreen.spec.ts diff --git a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/__tests__/useLedgerSelectAccountsScreen.spec.ts b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/__tests__/useLedgerSelectAccountsScreen.spec.ts new file mode 100644 index 000000000..61c89998b --- /dev/null +++ b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/__tests__/useLedgerSelectAccountsScreen.spec.ts @@ -0,0 +1,247 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, act, waitFor } from '@testing-library/react' +import type { + HardwareWalletDerivedAccount, + HardwareWalletTransport, +} from '@perawallet/wallet-core-hardware-wallet' + +const mockNavigate = vi.fn() +const mockGetAddress = vi.fn() +const mockDisconnectTransport = vi.fn() +const mockConnect = vi.fn() +const mockDisconnect = vi.fn() +const mockShowError = vi.fn() + +vi.mock('@hooks/useAppNavigation', () => ({ + useAppNavigation: () => ({ navigate: mockNavigate }), +})) + +vi.mock('@hooks/useLanguage', () => ({ + useLanguage: () => ({ + t: (key: string, options?: Record) => { + if (options && 'index' in options) { + return `Account #${String(options.index)}` + } + return key + }, + }), +})) + +vi.mock('@hooks/useToast', () => ({ + useToast: () => ({ + showToast: vi.fn(), + showInfo: vi.fn(), + showError: mockShowError, + showSuccess: vi.fn(), + }), +})) + +vi.mock('../../../hooks', () => ({ + useLedgerConnection: () => ({ + connect: mockConnect, + disconnect: mockDisconnect, + }), +})) + +vi.mock('@react-navigation/native', () => ({ + useRoute: () => ({ + params: { + deviceId: 'device-1', + deviceName: 'Fred Nano X', + accounts: [ + { + address: 'AAA111', + publicKey: new Uint8Array([1]), + accountIndex: 0, + }, + { + address: 'BBB222', + publicKey: new Uint8Array([2]), + accountIndex: 1, + }, + ], + }, + }), +})) + +vi.mock('@perawallet/wallet-core-accounts', () => ({ + useAllAccounts: () => [], +})) + +import { useLedgerSelectAccountsScreen } from '../useLedgerSelectAccountsScreen' + +const buildTransport = (): HardwareWalletTransport => + ({ + getAddress: mockGetAddress, + signTransaction: vi.fn(), + disconnect: mockDisconnectTransport, + }) as unknown as HardwareWalletTransport + +const buildAccount = ( + accountIndex: number, + address: string, +): HardwareWalletDerivedAccount => ({ + address, + publicKey: new Uint8Array([accountIndex]), + accountIndex, +}) + +describe('useLedgerSelectAccountsScreen', () => { + beforeEach(() => { + mockNavigate.mockReset() + mockGetAddress.mockReset() + mockDisconnectTransport.mockReset() + mockConnect.mockReset() + mockDisconnect.mockReset() + mockShowError.mockReset() + + const transport = buildTransport() + mockConnect.mockResolvedValue(transport) + mockDisconnectTransport.mockResolvedValue(undefined) + }) + + it('exposes the route accounts unchanged on initial render', () => { + const { result } = renderHook(() => useLedgerSelectAccountsScreen()) + + expect(result.current.accounts).toHaveLength(2) + expect(result.current.accounts[0].accountIndex).toBe(0) + expect(result.current.accounts[1].accountIndex).toBe(1) + expect(result.current.isFetchingMore).toBe(false) + }) + + it('appends one new account at the next index on handleFindAnother', async () => { + mockGetAddress.mockResolvedValueOnce(buildAccount(2, 'CCC333')) + + const { result } = renderHook(() => useLedgerSelectAccountsScreen()) + + await act(async () => { + await result.current.handleFindAnother() + }) + + expect(mockConnect).toHaveBeenCalledTimes(1) + expect(mockConnect).toHaveBeenCalledWith('device-1') + expect(mockGetAddress).toHaveBeenCalledTimes(1) + expect(mockGetAddress).toHaveBeenCalledWith(2, false) + expect(result.current.accounts).toHaveLength(3) + expect(result.current.accounts[2]).toEqual({ + address: 'CCC333', + publicKey: new Uint8Array([2]), + accountIndex: 2, + }) + expect(result.current.isFetchingMore).toBe(false) + }) + + it('reuses the connected transport across taps and increments index', async () => { + mockGetAddress + .mockResolvedValueOnce(buildAccount(2, 'CCC333')) + .mockResolvedValueOnce(buildAccount(3, 'DDD444')) + + const { result } = renderHook(() => useLedgerSelectAccountsScreen()) + + await act(async () => { + await result.current.handleFindAnother() + }) + await act(async () => { + await result.current.handleFindAnother() + }) + + expect(mockConnect).toHaveBeenCalledTimes(1) + expect(mockGetAddress).toHaveBeenNthCalledWith(1, 2, false) + expect(mockGetAddress).toHaveBeenNthCalledWith(2, 3, false) + expect(result.current.accounts).toHaveLength(4) + expect(result.current.accounts[3].accountIndex).toBe(3) + }) + + it('is a no-op while a fetch is in flight', async () => { + let resolveFetch: (value: HardwareWalletDerivedAccount) => void = () => {} + mockGetAddress.mockImplementationOnce( + () => + new Promise(resolve => { + resolveFetch = resolve + }), + ) + + const { result } = renderHook(() => useLedgerSelectAccountsScreen()) + + act(() => { + void result.current.handleFindAnother() + }) + + await waitFor(() => { + expect(result.current.isFetchingMore).toBe(true) + }) + + await act(async () => { + await result.current.handleFindAnother() + }) + + expect(mockGetAddress).toHaveBeenCalledTimes(1) + + await act(async () => { + resolveFetch(buildAccount(2, 'CCC333')) + }) + + await waitFor(() => { + expect(result.current.isFetchingMore).toBe(false) + }) + }) + + it('shows an error toast and clears loading when connect fails', async () => { + mockConnect.mockReset() + mockConnect.mockRejectedValueOnce(new Error('BLE unavailable')) + + const { result } = renderHook(() => useLedgerSelectAccountsScreen()) + + await act(async () => { + await result.current.handleFindAnother() + }) + + expect(mockShowError).toHaveBeenCalledTimes(1) + expect(result.current.accounts).toHaveLength(2) + expect(result.current.isFetchingMore).toBe(false) + }) + + it('shows an error toast and clears loading when getAddress fails', async () => { + mockGetAddress.mockRejectedValueOnce(new Error('Ledger app closed')) + + const { result } = renderHook(() => useLedgerSelectAccountsScreen()) + + await act(async () => { + await result.current.handleFindAnother() + }) + + expect(mockShowError).toHaveBeenCalledTimes(1) + expect(result.current.accounts).toHaveLength(2) + expect(result.current.isFetchingMore).toBe(false) + }) + + it('disconnects the transport on unmount after a successful tap', async () => { + mockGetAddress.mockResolvedValueOnce(buildAccount(2, 'CCC333')) + + const { result, unmount } = renderHook(() => + useLedgerSelectAccountsScreen(), + ) + + await act(async () => { + await result.current.handleFindAnother() + }) + + unmount() + + await waitFor(() => { + expect(mockDisconnectTransport).toHaveBeenCalledTimes(1) + }) + }) +}) From 2f5c09642c92a464c3c925a2786188235579c4ae Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:17:58 +0100 Subject: [PATCH 15/20] feat(ledger): add handleFindAnother to LedgerSelectAccounts hook [PERA-XXXX] --- .../useLedgerSelectAccountsScreen.spec.ts | 44 +++++++-- .../useLedgerSelectAccountsScreen.ts | 91 +++++++++++++++++-- 2 files changed, 122 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/__tests__/useLedgerSelectAccountsScreen.spec.ts b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/__tests__/useLedgerSelectAccountsScreen.spec.ts index 61c89998b..4b9455de3 100644 --- a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/__tests__/useLedgerSelectAccountsScreen.spec.ts +++ b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/__tests__/useLedgerSelectAccountsScreen.spec.ts @@ -22,7 +22,7 @@ const mockGetAddress = vi.fn() const mockDisconnectTransport = vi.fn() const mockConnect = vi.fn() const mockDisconnect = vi.fn() -const mockShowError = vi.fn() +const mockErrorToast = vi.fn() vi.mock('@hooks/useAppNavigation', () => ({ useAppNavigation: () => ({ navigate: mockNavigate }), @@ -42,9 +42,9 @@ vi.mock('@hooks/useLanguage', () => ({ vi.mock('@hooks/useToast', () => ({ useToast: () => ({ showToast: vi.fn(), - showInfo: vi.fn(), - showError: mockShowError, - showSuccess: vi.fn(), + infoToast: vi.fn(), + errorToast: mockErrorToast, + successToast: vi.fn(), }), })) @@ -105,7 +105,7 @@ describe('useLedgerSelectAccountsScreen', () => { mockDisconnectTransport.mockReset() mockConnect.mockReset() mockDisconnect.mockReset() - mockShowError.mockReset() + mockErrorToast.mockReset() const transport = buildTransport() mockConnect.mockResolvedValue(transport) @@ -208,7 +208,7 @@ describe('useLedgerSelectAccountsScreen', () => { await result.current.handleFindAnother() }) - expect(mockShowError).toHaveBeenCalledTimes(1) + expect(mockErrorToast).toHaveBeenCalledTimes(1) expect(result.current.accounts).toHaveLength(2) expect(result.current.isFetchingMore).toBe(false) }) @@ -222,11 +222,41 @@ describe('useLedgerSelectAccountsScreen', () => { await result.current.handleFindAnother() }) - expect(mockShowError).toHaveBeenCalledTimes(1) + expect(mockErrorToast).toHaveBeenCalledTimes(1) expect(result.current.accounts).toHaveLength(2) expect(result.current.isFetchingMore).toBe(false) }) + it('does not surface a toast or setState if the screen unmounts during a fetch', async () => { + let rejectFetch: (reason: Error) => void = () => {} + mockGetAddress.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectFetch = reject + }), + ) + + const { result, unmount } = renderHook(() => + useLedgerSelectAccountsScreen(), + ) + + act(() => { + void result.current.handleFindAnother() + }) + + await waitFor(() => { + expect(result.current.isFetchingMore).toBe(true) + }) + + unmount() + + await act(async () => { + rejectFetch(new Error('Connection lost')) + }) + + expect(mockErrorToast).not.toHaveBeenCalled() + }) + it('disconnects the transport on unmount after a successful tap', async () => { mockGetAddress.mockResolvedValueOnce(buildAccount(2, 'CCC333')) diff --git a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.ts b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.ts index a65952dc2..bbeb4662f 100644 --- a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.ts +++ b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.ts @@ -10,13 +10,18 @@ limitations under the License */ -import { useState, useMemo, useCallback } from 'react' +import { useState, useMemo, useCallback, useRef, useEffect } from 'react' import { RouteProp, useRoute } from '@react-navigation/native' import { useAppNavigation } from '@hooks/useAppNavigation' import { useLanguage } from '@hooks/useLanguage' +import { useToast } from '@hooks/useToast' import { useAllAccounts } from '@perawallet/wallet-core-accounts' import type { LedgerAccount } from '@perawallet/wallet-core-ledger' +import type { HardwareWalletTransport } from '@perawallet/wallet-core-hardware-wallet' import type { AddAccountStackParamList } from '@modules/onboarding/routes/types' +import { useLedgerConnection } from '../../hooks' +import { getLedgerErrorPreset } from '@modules/ledger/utils' +import type { Nullable } from '@perawallet/wallet-core-shared' type LedgerSelectAccountsRouteProp = RouteProp< AddAccountStackParamList, @@ -30,20 +35,59 @@ type UseLedgerSelectAccountsScreenResult = { areAllImported: boolean canContinue: boolean alreadyImportedAddresses: Set + isFetchingMore: boolean toggleSelection: (address: string) => void toggleSelectAll: () => void handleContinue: () => void + handleFindAnother: () => Promise t: (key: string, options?: Record) => string } export const useLedgerSelectAccountsScreen = (): UseLedgerSelectAccountsScreenResult => { const { - params: { deviceId, deviceName, accounts }, + params: { + deviceId, + deviceName, + accounts: routeAccounts, + }, } = useRoute() const { t } = useLanguage() const navigation = useAppNavigation() const allAccounts = useAllAccounts() + const { errorToast } = useToast() + const { connect } = useLedgerConnection() + + const [accounts, setAccounts] = + useState(routeAccounts) + const [isFetchingMore, setIsFetchingMore] = useState(false) + const [selectedAddresses, setSelectedAddresses] = useState>( + () => new Set(), + ) + + const transportRef = + useRef>(null) + const inFlightRef = useRef(false) + const isMountedRef = useRef(true) + const accountsRef = useRef(routeAccounts) + + useEffect(() => { + isMountedRef.current = true + return () => { + isMountedRef.current = false + } + }, []) + + useEffect(() => { + accountsRef.current = accounts + }, [accounts]) + + useEffect(() => { + return () => { + transportRef.current?.disconnect().catch(() => {}) + transportRef.current = null + } + }, []) const alreadyImportedAddresses = useMemo(() => { return new Set(allAccounts.map(acc => acc.address)) @@ -55,10 +99,6 @@ export const useLedgerSelectAccountsScreen = ) }, [accounts, alreadyImportedAddresses]) - const [selectedAddresses, setSelectedAddresses] = useState>( - () => new Set(), - ) - const isAllSelected = newAccounts.length > 0 && selectedAddresses.size === newAccounts.length @@ -104,6 +144,43 @@ export const useLedgerSelectAccountsScreen = }) }, [accounts, selectedAddresses, deviceId, deviceName, navigation]) + const handleFindAnother = useCallback(async () => { + if (inFlightRef.current) return + inFlightRef.current = true + setIsFetchingMore(true) + try { + if (!transportRef.current) { + transportRef.current = await connect(deviceId) + } + + const nextIndex = + accountsRef.current.reduce( + (max, acc) => Math.max(max, acc.accountIndex), + -1, + ) + 1 + + const next = await transportRef.current.getAddress( + nextIndex, + false, + ) + + if (!isMountedRef.current) return + + setAccounts(prev => [...prev, next]) + } catch (err) { + if (!isMountedRef.current) return + const error = + err instanceof Error ? err : new Error(String(err)) + const preset = getLedgerErrorPreset(error, t) + errorToast(preset.title, preset.body) + } finally { + inFlightRef.current = false + if (isMountedRef.current) { + setIsFetchingMore(false) + } + } + }, [connect, deviceId, errorToast, t]) + const areAllImported = newAccounts.length === 0 const canContinue = areAllImported || selectedAddresses.size > 0 @@ -114,9 +191,11 @@ export const useLedgerSelectAccountsScreen = areAllImported, canContinue, alreadyImportedAddresses, + isFetchingMore, toggleSelection, toggleSelectAll, handleContinue, + handleFindAnother, t, } } From 8a62d7c155a297cc54e6b888946e118106a10870 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:42:51 +0100 Subject: [PATCH 16/20] feat(ledger): render FindAnotherWalletRow in select-accounts screen [PERA-XXXX] --- .../LedgerSelectAccountsScreen.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/LedgerSelectAccountsScreen.tsx b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/LedgerSelectAccountsScreen.tsx index c8c4873cf..a76534ce2 100644 --- a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/LedgerSelectAccountsScreen.tsx +++ b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/LedgerSelectAccountsScreen.tsx @@ -25,6 +25,7 @@ import type { LedgerAccount } from '@perawallet/wallet-core-ledger' import { truncateAlgorandAddress } from '@perawallet/wallet-core-shared' import { useStyles } from './styles' import { useLedgerSelectAccountsScreen } from './useLedgerSelectAccountsScreen' +import { FindAnotherWalletRow } from './FindAnotherWalletRow' export const LedgerSelectAccountsScreen = () => { const styles = useStyles() @@ -35,9 +36,11 @@ export const LedgerSelectAccountsScreen = () => { areAllImported, canContinue, alreadyImportedAddresses, + isFetchingMore, toggleSelection, toggleSelectAll, handleContinue, + handleFindAnother, t, } = useLedgerSelectAccountsScreen() @@ -88,6 +91,15 @@ export const LedgerSelectAccountsScreen = () => { ) } + const renderFooter = () => ( + + ) + return ( @@ -144,6 +156,7 @@ export const LedgerSelectAccountsScreen = () => { extraData={selectedAddresses} contentContainerStyle={styles.listContent} showsVerticalScrollIndicator={false} + ListFooterComponent={renderFooter} /> From fedd67cf09e95cf141a6aa3749e3b8d627c35156 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:43:02 +0100 Subject: [PATCH 17/20] chore: format and update copyright header --- .../FindAnotherWalletRow/FindAnotherWalletRow.tsx | 4 +--- .../__tests__/FindAnotherWalletRow.spec.tsx | 4 +--- .../__tests__/useLedgerSelectAccountsScreen.spec.ts | 12 ++++++++---- .../useLedgerSelectAccountsScreen.ts | 12 +++--------- 4 files changed, 13 insertions(+), 19 deletions(-) diff --git a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/FindAnotherWalletRow/FindAnotherWalletRow.tsx b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/FindAnotherWalletRow/FindAnotherWalletRow.tsx index 2a4c4ae7d..e584e95e9 100644 --- a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/FindAnotherWalletRow/FindAnotherWalletRow.tsx +++ b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/FindAnotherWalletRow/FindAnotherWalletRow.tsx @@ -39,9 +39,7 @@ export const FindAnotherWalletRow = ({ {isLoading ? ( ) : ( diff --git a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/FindAnotherWalletRow/__tests__/FindAnotherWalletRow.spec.tsx b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/FindAnotherWalletRow/__tests__/FindAnotherWalletRow.spec.tsx index 7ff8fafff..56bcc5526 100644 --- a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/FindAnotherWalletRow/__tests__/FindAnotherWalletRow.spec.tsx +++ b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/FindAnotherWalletRow/__tests__/FindAnotherWalletRow.spec.tsx @@ -55,8 +55,6 @@ describe('FindAnotherWalletRow', () => { />, ) - expect( - screen.getByTestId('find-another-row-spinner'), - ).toBeTruthy() + expect(screen.getByTestId('find-another-row-spinner')).toBeTruthy() }) }) diff --git a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/__tests__/useLedgerSelectAccountsScreen.spec.ts b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/__tests__/useLedgerSelectAccountsScreen.spec.ts index 4b9455de3..a8b3c54c5 100644 --- a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/__tests__/useLedgerSelectAccountsScreen.spec.ts +++ b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/__tests__/useLedgerSelectAccountsScreen.spec.ts @@ -165,7 +165,9 @@ describe('useLedgerSelectAccountsScreen', () => { }) it('is a no-op while a fetch is in flight', async () => { - let resolveFetch: (value: HardwareWalletDerivedAccount) => void = () => {} + let resolveFetch: ( + value: HardwareWalletDerivedAccount, + ) => void = () => {} mockGetAddress.mockImplementationOnce( () => new Promise(resolve => { @@ -231,9 +233,11 @@ describe('useLedgerSelectAccountsScreen', () => { let rejectFetch: (reason: Error) => void = () => {} mockGetAddress.mockImplementationOnce( () => - new Promise((_resolve, reject) => { - rejectFetch = reject - }), + new Promise( + (_resolve, reject) => { + rejectFetch = reject + }, + ), ) const { result, unmount } = renderHook(() => diff --git a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.ts b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.ts index bbeb4662f..36c8ee70d 100644 --- a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.ts +++ b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.ts @@ -46,11 +46,7 @@ type UseLedgerSelectAccountsScreenResult = { export const useLedgerSelectAccountsScreen = (): UseLedgerSelectAccountsScreenResult => { const { - params: { - deviceId, - deviceName, - accounts: routeAccounts, - }, + params: { deviceId, deviceName, accounts: routeAccounts }, } = useRoute() const { t } = useLanguage() const navigation = useAppNavigation() @@ -58,15 +54,13 @@ export const useLedgerSelectAccountsScreen = const { errorToast } = useToast() const { connect } = useLedgerConnection() - const [accounts, setAccounts] = - useState(routeAccounts) + const [accounts, setAccounts] = useState(routeAccounts) const [isFetchingMore, setIsFetchingMore] = useState(false) const [selectedAddresses, setSelectedAddresses] = useState>( () => new Set(), ) - const transportRef = - useRef>(null) + const transportRef = useRef>(null) const inFlightRef = useRef(false) const isMountedRef = useRef(true) const accountsRef = useRef(routeAccounts) From 2b330f6f2606b6430d1446ea7550a9575182c8e5 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:09:00 +0100 Subject: [PATCH 18/20] feat(ledger): block Continue while fetching another wallet [PERA-XXXX] Prevents a user from tapping Continue mid-fetch, which would otherwise silently drop the in-flight account. --- .../useLedgerSelectAccountsScreen.spec.ts | 38 +++++++++++++++++++ .../useLedgerSelectAccountsScreen.ts | 4 +- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/__tests__/useLedgerSelectAccountsScreen.spec.ts b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/__tests__/useLedgerSelectAccountsScreen.spec.ts index a8b3c54c5..a68e373b7 100644 --- a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/__tests__/useLedgerSelectAccountsScreen.spec.ts +++ b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/__tests__/useLedgerSelectAccountsScreen.spec.ts @@ -261,6 +261,44 @@ describe('useLedgerSelectAccountsScreen', () => { expect(mockErrorToast).not.toHaveBeenCalled() }) + it('blocks canContinue while a fetch is in flight', async () => { + let resolveFetch: ( + value: HardwareWalletDerivedAccount, + ) => void = () => {} + mockGetAddress.mockImplementationOnce( + () => + new Promise(resolve => { + resolveFetch = resolve + }), + ) + + const { result } = renderHook(() => useLedgerSelectAccountsScreen()) + + act(() => { + result.current.toggleSelection('AAA111') + }) + + expect(result.current.canContinue).toBe(true) + + act(() => { + void result.current.handleFindAnother() + }) + + await waitFor(() => { + expect(result.current.isFetchingMore).toBe(true) + }) + + expect(result.current.canContinue).toBe(false) + + await act(async () => { + resolveFetch(buildAccount(2, 'CCC333')) + }) + + await waitFor(() => { + expect(result.current.canContinue).toBe(true) + }) + }) + it('disconnects the transport on unmount after a successful tap', async () => { mockGetAddress.mockResolvedValueOnce(buildAccount(2, 'CCC333')) diff --git a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.ts b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.ts index 36c8ee70d..f5b9c4047 100644 --- a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.ts +++ b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.ts @@ -176,7 +176,9 @@ export const useLedgerSelectAccountsScreen = }, [connect, deviceId, errorToast, t]) const areAllImported = newAccounts.length === 0 - const canContinue = areAllImported || selectedAddresses.size > 0 + const canContinue = + !isFetchingMore && + (areAllImported || selectedAddresses.size > 0) return { accounts, From d49048b6af6e5539d188d86fc948368e8c23de68 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:09:14 +0100 Subject: [PATCH 19/20] chore: format and update copyright header --- .../useLedgerSelectAccountsScreen.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.ts b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.ts index f5b9c4047..1c9709c7e 100644 --- a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.ts +++ b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.ts @@ -177,8 +177,7 @@ export const useLedgerSelectAccountsScreen = const areAllImported = newAccounts.length === 0 const canContinue = - !isFetchingMore && - (areAllImported || selectedAddresses.size > 0) + !isFetchingMore && (areAllImported || selectedAddresses.size > 0) return { accounts, From 721936842b1102ea7957ac7d6bee19479727812b Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:23:21 +0100 Subject: [PATCH 20/20] fix(app): resolve merge conflict in App.tsx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop duplicate isDarkMode/getTheme declarations and the redundant bootstrapTheme. The hoisted top-level ThemeProvider on this branch remains the sole provider — RootComponent no longer ships its own. --- apps/mobile/src/App.tsx | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index c7f7d28b5..80cd0bc92 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -46,10 +46,7 @@ import { usePeraProvider, } from '@perawallet/wallet-extension-provider' import { SafeAreaProvider } from 'react-native-safe-area-context' -import { ThemeProvider } from '@rneui/themed' import { RootComponent } from '@components/RootComponent' -import { getTheme } from '@theme/theme' -import { useIsDarkMode } from '@hooks/useIsDarkMode' import * as SplashScreen from 'expo-splash-screen' // Keep the splash screen visible while we fetch resources @@ -78,8 +75,6 @@ const AppContent = () => { const [bootstrapped, setBootstrapped] = useState(false) const [fcmToken, setFcmToken] = useState>(null) const { t } = useLanguage() - const isDarkMode = useIsDarkMode() - const bootstrapTheme = getTheme(isDarkMode ? 'dark' : 'light') const provider = usePeraProvider() const isDarkMode = useIsDarkMode() const theme = getTheme(isDarkMode ? 'dark' : 'light') @@ -139,11 +134,7 @@ const AppContent = () => { return ( - {!bootstrapped && ( - - {t('common.loading.label')} - - )} + {!bootstrapped && {t('common.loading.label')}} {bootstrapped && persister && (