From 6aa215515c0fd2d5ea1553ff5609fe1ddbbb3c59 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 18 May 2026 12:30:49 +0100 Subject: [PATCH 01/39] feat(accounts): surface authAddress in on-chain account information --- .../src/hooks/__tests__/mappers.spec.ts | 47 +++++++++++++++++++ packages/accounts/src/hooks/mappers.ts | 29 +++++++----- packages/blockchain/src/models/index.ts | 2 + 3 files changed, 66 insertions(+), 12 deletions(-) create mode 100644 packages/accounts/src/hooks/__tests__/mappers.spec.ts diff --git a/packages/accounts/src/hooks/__tests__/mappers.spec.ts b/packages/accounts/src/hooks/__tests__/mappers.spec.ts new file mode 100644 index 000000000..015e6deda --- /dev/null +++ b/packages/accounts/src/hooks/__tests__/mappers.spec.ts @@ -0,0 +1,47 @@ +/* + 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 } from 'vitest' +import { mapOnChainAccountInformation } from '../mappers' +import type { OnChainAccountInformationResponse } from '../endpoints' + +const baseResponse = { + address: 'ADDR', + amount: 1_000_000n, + minBalance: 100_000n, + status: 'Offline', + rewards: 0n, + assets: [{ assetId: 10n, amount: 5n, isFrozen: false }], +} + +describe('mapOnChainAccountInformation', () => { + it('maps balance, assets and omits authAddress when not rekeyed', () => { + const result = mapOnChainAccountInformation( + baseResponse as unknown as OnChainAccountInformationResponse, + ) + + expect(result.amount).toBe(1_000_000n) + expect(result.assets).toEqual([ + { assetId: 10n, amount: 5n, isFrozen: false }, + ]) + expect(result.authAddress).toBeUndefined() + }) + + it('surfaces authAddress as a string when the account is rekeyed', () => { + const result = mapOnChainAccountInformation({ + ...baseResponse, + authAddr: { toString: () => 'AUTHADDR' }, + } as unknown as OnChainAccountInformationResponse) + + expect(result.authAddress).toBe('AUTHADDR') + }) +}) diff --git a/packages/accounts/src/hooks/mappers.ts b/packages/accounts/src/hooks/mappers.ts index 233c00e86..c9ebd6eb7 100644 --- a/packages/accounts/src/hooks/mappers.ts +++ b/packages/accounts/src/hooks/mappers.ts @@ -15,15 +15,20 @@ import type { OnChainAccountInformationResponse } from './endpoints' export const mapOnChainAccountInformation = ( response: OnChainAccountInformationResponse, -): AccountInformation => ({ - address: response.address, - amount: response.amount, - minBalance: response.minBalance, - status: response.status, - rewards: response.rewards, - assets: (response.assets ?? []).map(asset => ({ - assetId: asset.assetId, - amount: asset.amount, - isFrozen: asset.isFrozen, - })), -}) +): AccountInformation => { + const authAddr = (response as { authAddr?: { toString(): string } }) + .authAddr + return { + address: response.address, + amount: response.amount, + minBalance: response.minBalance, + status: response.status, + rewards: response.rewards, + assets: (response.assets ?? []).map(asset => ({ + assetId: asset.assetId, + amount: asset.amount, + isFrozen: asset.isFrozen, + })), + authAddress: authAddr ? authAddr.toString() : undefined, + } +} diff --git a/packages/blockchain/src/models/index.ts b/packages/blockchain/src/models/index.ts index 22d1dbf0e..a4f7f37ab 100644 --- a/packages/blockchain/src/models/index.ts +++ b/packages/blockchain/src/models/index.ts @@ -40,6 +40,8 @@ export type AccountInformation = { rewards: bigint /** Opted-in assets with amounts in base units (smallest indivisible unit) */ assets: Array<{ assetId: bigint; amount: bigint; isFrozen: boolean }> + /** Auth (signer) address when the account is rekeyed; undefined otherwise */ + authAddress?: string } export type PeraTransaction = Transaction From 02fa617f03172ba3dfbd630035a4793ed75f7c7b Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 18 May 2026 12:36:25 +0100 Subject: [PATCH 02/39] test(accounts): consolidate mapper tests into mappers.spec.ts --- .../src/hooks/__tests__/mappers.spec.ts | 79 ++++++++++++++----- .../src/hooks/__tests__/mappers.test.ts | 70 ---------------- 2 files changed, 59 insertions(+), 90 deletions(-) delete mode 100644 packages/accounts/src/hooks/__tests__/mappers.test.ts diff --git a/packages/accounts/src/hooks/__tests__/mappers.spec.ts b/packages/accounts/src/hooks/__tests__/mappers.spec.ts index 015e6deda..06115c7b5 100644 --- a/packages/accounts/src/hooks/__tests__/mappers.spec.ts +++ b/packages/accounts/src/hooks/__tests__/mappers.spec.ts @@ -14,34 +14,73 @@ import { describe, it, expect } from 'vitest' import { mapOnChainAccountInformation } from '../mappers' import type { OnChainAccountInformationResponse } from '../endpoints' -const baseResponse = { - address: 'ADDR', - amount: 1_000_000n, - minBalance: 100_000n, - status: 'Offline', - rewards: 0n, - assets: [{ assetId: 10n, amount: 5n, isFrozen: false }], -} +const buildResponse = ( + overrides: Partial = {}, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): OnChainAccountInformationResponse => + ({ + address: 'ADDR1', + amount: 1000n, + minBalance: 100n, + status: 'Online', + rewards: 0n, + assets: [], + ...overrides, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any describe('mapOnChainAccountInformation', () => { - it('maps balance, assets and omits authAddress when not rekeyed', () => { - const result = mapOnChainAccountInformation( - baseResponse as unknown as OnChainAccountInformationResponse, + it('maps top-level fields and empty asset list', () => { + const mapped = mapOnChainAccountInformation(buildResponse()) + + expect(mapped).toEqual({ + address: 'ADDR1', + amount: 1000n, + minBalance: 100n, + status: 'Online', + rewards: 0n, + assets: [], + }) + }) + + it('maps assets array preserving assetId/amount/isFrozen', () => { + const mapped = mapOnChainAccountInformation( + buildResponse({ + assets: [ + { assetId: 1n, amount: 50n, isFrozen: false }, + { assetId: 2n, amount: 0n, isFrozen: true }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any, + }), ) - expect(result.amount).toBe(1_000_000n) - expect(result.assets).toEqual([ - { assetId: 10n, amount: 5n, isFrozen: false }, + expect(mapped.assets).toEqual([ + { assetId: 1n, amount: 50n, isFrozen: false }, + { assetId: 2n, amount: 0n, isFrozen: true }, ]) - expect(result.authAddress).toBeUndefined() + }) + + it('returns an empty array when assets is undefined', () => { + const mapped = mapOnChainAccountInformation( + buildResponse({ assets: undefined }), + ) + + expect(mapped.assets).toEqual([]) + }) + + it('omits authAddress when the account is not rekeyed', () => { + const mapped = mapOnChainAccountInformation(buildResponse()) + + expect(mapped.authAddress).toBeUndefined() }) it('surfaces authAddress as a string when the account is rekeyed', () => { - const result = mapOnChainAccountInformation({ - ...baseResponse, - authAddr: { toString: () => 'AUTHADDR' }, - } as unknown as OnChainAccountInformationResponse) + const mapped = mapOnChainAccountInformation( + buildResponse({ + authAddr: { toString: () => 'AUTHADDR' }, + } as any), + ) - expect(result.authAddress).toBe('AUTHADDR') + expect(mapped.authAddress).toBe('AUTHADDR') }) }) diff --git a/packages/accounts/src/hooks/__tests__/mappers.test.ts b/packages/accounts/src/hooks/__tests__/mappers.test.ts deleted file mode 100644 index 5784ff7c0..000000000 --- a/packages/accounts/src/hooks/__tests__/mappers.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* - 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 } from 'vitest' -import { mapOnChainAccountInformation } from '../mappers' -import type { OnChainAccountInformationResponse } from '../endpoints' - -const buildResponse = ( - overrides: Partial = {}, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -): OnChainAccountInformationResponse => - ({ - address: 'ADDR1', - amount: 1000n, - minBalance: 100n, - status: 'Online', - rewards: 0n, - assets: [], - ...overrides, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }) as any - -describe('mapOnChainAccountInformation', () => { - it('maps top-level fields and empty asset list', () => { - const mapped = mapOnChainAccountInformation(buildResponse()) - - expect(mapped).toEqual({ - address: 'ADDR1', - amount: 1000n, - minBalance: 100n, - status: 'Online', - rewards: 0n, - assets: [], - }) - }) - - it('maps assets array preserving assetId/amount/isFrozen', () => { - const mapped = mapOnChainAccountInformation( - buildResponse({ - assets: [ - { assetId: 1n, amount: 50n, isFrozen: false }, - { assetId: 2n, amount: 0n, isFrozen: true }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ] as any, - }), - ) - - expect(mapped.assets).toEqual([ - { assetId: 1n, amount: 50n, isFrozen: false }, - { assetId: 2n, amount: 0n, isFrozen: true }, - ]) - }) - - it('returns an empty array when assets is undefined', () => { - const mapped = mapOnChainAccountInformation( - buildResponse({ assets: undefined }), - ) - - expect(mapped.assets).toEqual([]) - }) -}) From be5a86f2c83a0f1d7a649637a20c34d62d40c115 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 18 May 2026 12:39:02 +0100 Subject: [PATCH 03/39] feat(accounts): add useRekeyedAddressesQuery --- .../useRekeyedAddressesQuery.test.ts | 78 +++++++++++++++++++ packages/accounts/src/hooks/index.ts | 1 + packages/accounts/src/hooks/querykeys.ts | 5 ++ .../src/hooks/useRekeyedAddressesQuery.ts | 27 +++++++ 4 files changed, 111 insertions(+) create mode 100644 packages/accounts/src/hooks/__tests__/useRekeyedAddressesQuery.test.ts create mode 100644 packages/accounts/src/hooks/useRekeyedAddressesQuery.ts diff --git a/packages/accounts/src/hooks/__tests__/useRekeyedAddressesQuery.test.ts b/packages/accounts/src/hooks/__tests__/useRekeyedAddressesQuery.test.ts new file mode 100644 index 000000000..73503648d --- /dev/null +++ b/packages/accounts/src/hooks/__tests__/useRekeyedAddressesQuery.test.ts @@ -0,0 +1,78 @@ +/* + 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, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import React from 'react' +import { useRekeyedAddressesQuery } from '../useRekeyedAddressesQuery' +import { getRekeyedAddressesQueryKey } from '../querykeys' + +const mocks = vi.hoisted(() => ({ + fetchRekeyedAddresses: vi.fn(), +})) + +vi.mock('../../account-discovery', () => ({ + fetchRekeyedAddresses: mocks.fetchRekeyedAddresses, +})) + +vi.mock('@perawallet/wallet-core-blockchain', () => ({ + useNetwork: () => ({ network: 'mainnet' }), +})) + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + return ({ children }: { children: React.ReactNode }) => + React.createElement( + QueryClientProvider, + { client: queryClient }, + children, + ) +} + +describe('useRekeyedAddressesQuery', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('builds the expected query key', () => { + expect(getRekeyedAddressesQueryKey('ADDR', 'mainnet')).toEqual([ + 'accounts', + 'rekeyed-addresses', + { address: 'ADDR', network: 'mainnet' }, + ]) + }) + + it('returns the addresses rekeyed to the given address', async () => { + mocks.fetchRekeyedAddresses.mockResolvedValue(['REKEYED1', 'REKEYED2']) + + const { result } = renderHook( + () => useRekeyedAddressesQuery('ADDR'), + { wrapper: createWrapper() }, + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data).toEqual(['REKEYED1', 'REKEYED2']) + expect(mocks.fetchRekeyedAddresses).toHaveBeenCalledWith('ADDR') + }) + + it('is disabled when address is empty', () => { + const { result } = renderHook(() => useRekeyedAddressesQuery(''), { + wrapper: createWrapper(), + }) + + expect(result.current.fetchStatus).toBe('idle') + expect(mocks.fetchRekeyedAddresses).not.toHaveBeenCalled() + }) +}) diff --git a/packages/accounts/src/hooks/index.ts b/packages/accounts/src/hooks/index.ts index 8ba1404ff..bb4017659 100644 --- a/packages/accounts/src/hooks/index.ts +++ b/packages/accounts/src/hooks/index.ts @@ -40,6 +40,7 @@ export * from './useSortedAssetBalances' export * from './useAllAccountLogicalTypes' export * from './useAccountLogicalType' export * from './useRekeyTransition' +export * from './useRekeyedAddressesQuery' export * from './useOwnedAssets' export * from './useHDImportSession' export { invalidateAccountQueries, isAccountQuery } from './querykeys' diff --git a/packages/accounts/src/hooks/querykeys.ts b/packages/accounts/src/hooks/querykeys.ts index 783042f76..c31695913 100644 --- a/packages/accounts/src/hooks/querykeys.ts +++ b/packages/accounts/src/hooks/querykeys.ts @@ -45,6 +45,11 @@ export const getOnChainAccountInformationQueryKey = ( network: Network, ) => [MODULE_PREFIX, 'on-chain-account-information', { address, network }] +export const getRekeyedAddressesQueryKey = ( + address: string, + network: Network, +) => [MODULE_PREFIX, 'rekeyed-addresses', { address, network }] + export const getOwnedAssetIdsQueryKey = (network: Network) => [ MODULE_PREFIX, 'owned-asset-ids', diff --git a/packages/accounts/src/hooks/useRekeyedAddressesQuery.ts b/packages/accounts/src/hooks/useRekeyedAddressesQuery.ts new file mode 100644 index 000000000..698d7ec50 --- /dev/null +++ b/packages/accounts/src/hooks/useRekeyedAddressesQuery.ts @@ -0,0 +1,27 @@ +/* + 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 { useQuery } from '@tanstack/react-query' +import { useNetwork } from '@perawallet/wallet-core-blockchain' +import { fetchRekeyedAddresses } from '../account-discovery' +import { getRekeyedAddressesQueryKey } from './querykeys' + +export const useRekeyedAddressesQuery = (address: string) => { + const { network } = useNetwork() + + return useQuery({ + queryKey: getRekeyedAddressesQueryKey(address, network), + queryFn: () => fetchRekeyedAddresses(address), + enabled: !!address, + staleTime: 0, + }) +} From d08cabab0ad1df8fe6ad7a27346a97a296e192a3 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 18 May 2026 12:45:23 +0100 Subject: [PATCH 04/39] feat(accounts): add useLedgerAccountPreview composite hook --- .../__tests__/useLedgerAccountPreview.test.ts | 267 ++++++++++++++++++ packages/accounts/src/hooks/index.ts | 1 + .../src/hooks/useLedgerAccountPreview.ts | 133 +++++++++ packages/accounts/src/models/index.ts | 1 + .../src/models/ledger-account-preview.ts | 50 ++++ 5 files changed, 452 insertions(+) create mode 100644 packages/accounts/src/hooks/__tests__/useLedgerAccountPreview.test.ts create mode 100644 packages/accounts/src/hooks/useLedgerAccountPreview.ts create mode 100644 packages/accounts/src/models/ledger-account-preview.ts diff --git a/packages/accounts/src/hooks/__tests__/useLedgerAccountPreview.test.ts b/packages/accounts/src/hooks/__tests__/useLedgerAccountPreview.test.ts new file mode 100644 index 000000000..6a7608a29 --- /dev/null +++ b/packages/accounts/src/hooks/__tests__/useLedgerAccountPreview.test.ts @@ -0,0 +1,267 @@ +/* + 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 } from '@testing-library/react' +import { Decimal } from 'decimal.js' +import { useLedgerAccountPreview } from '../useLedgerAccountPreview' + +const mocks = vi.hoisted(() => ({ + useOnChainAccountInformationQuery: vi.fn(), + useRekeyedAddressesQuery: vi.fn(), + useAssetsQuery: vi.fn(), + useAssetPricesQuery: vi.fn(), + useCurrency: vi.fn(), +})) + +vi.mock('../useOnChainAccountInformationQuery', () => ({ + useOnChainAccountInformationQuery: + mocks.useOnChainAccountInformationQuery, +})) +vi.mock('../useRekeyedAddressesQuery', () => ({ + useRekeyedAddressesQuery: mocks.useRekeyedAddressesQuery, +})) +vi.mock('@perawallet/wallet-core-assets', async () => { + const actual = await vi.importActual< + typeof import('@perawallet/wallet-core-assets') + >('@perawallet/wallet-core-assets') + return { + ...actual, + useAssetsQuery: mocks.useAssetsQuery, + useAssetPricesQuery: mocks.useAssetPricesQuery, + } +}) +vi.mock('@perawallet/wallet-core-currencies', () => ({ + useCurrency: mocks.useCurrency, +})) + +const ALGO_ID = '0' + +beforeEach(() => { + vi.clearAllMocks() + mocks.useCurrency.mockReturnValue({ + usdToPreferred: (usd: Decimal) => usd, // 1:1 USD for tests + }) + mocks.useAssetsQuery.mockReturnValue({ data: new Map(), isPending: false }) + mocks.useAssetPricesQuery.mockReturnValue({ + data: new Map([[ALGO_ID, { assetId: ALGO_ID, usdPrice: new Decimal(2) }]]), + isPending: false, + }) + mocks.useRekeyedAddressesQuery.mockReturnValue({ + data: [], + isLoading: false, + isError: false, + }) +}) + +describe('useLedgerAccountPreview', () => { + it('composes ALGO balance and total fiat value', () => { + mocks.useOnChainAccountInformationQuery.mockReturnValue({ + data: { + address: 'ADDR', + amount: 3_000_000n, + minBalance: 100_000n, + status: 'Offline', + rewards: 0n, + assets: [], + }, + isLoading: false, + isError: false, + refetch: vi.fn(), + }) + + const { result } = renderHook(() => useLedgerAccountPreview('ADDR')) + + expect(result.current.isLoading).toBe(false) + expect(result.current.preview?.algoBalance.toString()).toBe('3') + expect(result.current.preview?.totalFiatValue.toString()).toBe('6') + expect(result.current.preview?.assets).toHaveLength(1) + expect(result.current.preview?.assets[0].isAlgo).toBe(true) + expect(result.current.preview?.assets[0].unitName).toBe('ALGO') + }) + + it('includes ASA holdings with metadata, fiat value and verification tier', () => { + mocks.useOnChainAccountInformationQuery.mockReturnValue({ + data: { + address: 'ADDR', + amount: 0n, + minBalance: 0n, + status: 'Offline', + rewards: 0n, + assets: [{ assetId: 31566704n, amount: 1_500_000n, isFrozen: false }], + }, + isLoading: false, + isError: false, + refetch: vi.fn(), + }) + mocks.useAssetsQuery.mockReturnValue({ + data: new Map([ + [ + '31566704', + { + assetId: '31566704', + name: 'USDC', + unitName: 'USDC', + decimals: 6, + peraMetadata: { + verificationTier: 'verified', + logo: 'https://logo', + }, + }, + ], + ]), + isPending: false, + }) + mocks.useAssetPricesQuery.mockReturnValue({ + data: new Map([ + ['0', { assetId: '0', usdPrice: new Decimal(0) }], + ['31566704', { assetId: '31566704', usdPrice: new Decimal(1) }], + ]), + isPending: false, + }) + + const { result } = renderHook(() => useLedgerAccountPreview('ADDR')) + + const usdc = result.current.preview?.assets.find( + a => a.assetId === '31566704', + ) + expect(usdc?.amount.toString()).toBe('1.5') + expect(usdc?.fiatValue.toString()).toBe('1.5') + expect(usdc?.verificationTier).toBe('verified') + expect(usdc?.name).toBe('USDC') + }) + + it('reports rekeyedTo when the account is rekeyed', () => { + mocks.useOnChainAccountInformationQuery.mockReturnValue({ + data: { + address: 'ADDR', + amount: 0n, + minBalance: 0n, + status: 'Offline', + rewards: 0n, + assets: [], + authAddress: 'AUTHADDR', + }, + isLoading: false, + isError: false, + refetch: vi.fn(), + }) + mocks.useRekeyedAddressesQuery.mockReturnValue({ + data: ['SOMEONE'], + isLoading: false, + isError: false, + }) + + const { result } = renderHook(() => useLedgerAccountPreview('ADDR')) + + expect(result.current.preview?.rekey).toEqual({ + kind: 'rekeyedTo', + authAddress: 'AUTHADDR', + }) + }) + + it('reports canSignFor when accounts are rekeyed to it and it is not rekeyed', () => { + mocks.useOnChainAccountInformationQuery.mockReturnValue({ + data: { + address: 'ADDR', + amount: 0n, + minBalance: 0n, + status: 'Offline', + rewards: 0n, + assets: [], + }, + isLoading: false, + isError: false, + refetch: vi.fn(), + }) + mocks.useRekeyedAddressesQuery.mockReturnValue({ + data: ['R1', 'R2'], + isLoading: false, + isError: false, + }) + + const { result } = renderHook(() => useLedgerAccountPreview('ADDR')) + + expect(result.current.preview?.rekey).toEqual({ + kind: 'canSignFor', + addresses: ['R1', 'R2'], + }) + }) + + it('reports rekey none when neither applies', () => { + mocks.useOnChainAccountInformationQuery.mockReturnValue({ + data: { + address: 'ADDR', + amount: 0n, + minBalance: 0n, + status: 'Offline', + rewards: 0n, + assets: [], + }, + isLoading: false, + isError: false, + refetch: vi.fn(), + }) + + const { result } = renderHook(() => useLedgerAccountPreview('ADDR')) + + expect(result.current.preview?.rekey).toEqual({ kind: 'none' }) + }) + + it('surfaces loading and error from the on-chain query', () => { + mocks.useOnChainAccountInformationQuery.mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + refetch: vi.fn(), + }) + + const { result } = renderHook(() => useLedgerAccountPreview('ADDR')) + expect(result.current.isLoading).toBe(true) + expect(result.current.preview).toBeUndefined() + + mocks.useOnChainAccountInformationQuery.mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + refetch: vi.fn(), + }) + const { result: errResult } = renderHook(() => + useLedgerAccountPreview('ADDR'), + ) + expect(errResult.current.isError).toBe(true) + }) + + it('degrades rekey to none when the rekeyed-addresses query errors', () => { + mocks.useOnChainAccountInformationQuery.mockReturnValue({ + data: { + address: 'ADDR', + amount: 0n, + minBalance: 0n, + status: 'Offline', + rewards: 0n, + assets: [], + }, + isLoading: false, + isError: false, + refetch: vi.fn(), + }) + mocks.useRekeyedAddressesQuery.mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + }) + + const { result } = renderHook(() => useLedgerAccountPreview('ADDR')) + expect(result.current.preview?.rekey).toEqual({ kind: 'none' }) + }) +}) diff --git a/packages/accounts/src/hooks/index.ts b/packages/accounts/src/hooks/index.ts index bb4017659..ed747d544 100644 --- a/packages/accounts/src/hooks/index.ts +++ b/packages/accounts/src/hooks/index.ts @@ -41,6 +41,7 @@ export * from './useAllAccountLogicalTypes' export * from './useAccountLogicalType' export * from './useRekeyTransition' export * from './useRekeyedAddressesQuery' +export * from './useLedgerAccountPreview' export * from './useOwnedAssets' export * from './useHDImportSession' export { invalidateAccountQueries, isAccountQuery } from './querykeys' diff --git a/packages/accounts/src/hooks/useLedgerAccountPreview.ts b/packages/accounts/src/hooks/useLedgerAccountPreview.ts new file mode 100644 index 000000000..f027c410a --- /dev/null +++ b/packages/accounts/src/hooks/useLedgerAccountPreview.ts @@ -0,0 +1,133 @@ +/* + 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 { Decimal } from 'decimal.js' +import { + useAssetsQuery, + useAssetPricesQuery, + ALGO_ASSET_ID, + ALGO_ASSET, + PeraAssetVerificationTier, +} from '@perawallet/wallet-core-assets' +import { + baseUnitsToDisplayUnits, + microAlgosToAlgos, +} from '@perawallet/wallet-core-blockchain' +import { useCurrency } from '@perawallet/wallet-core-currencies' +import type { + LedgerAccountPreview, + LedgerAccountPreviewAsset, + LedgerAccountRekeyRelationship, + UseLedgerAccountPreviewResult, +} from '../models' +import { useOnChainAccountInformationQuery } from './useOnChainAccountInformationQuery' +import { useRekeyedAddressesQuery } from './useRekeyedAddressesQuery' + +export const useLedgerAccountPreview = ( + address: string, +): UseLedgerAccountPreviewResult => { + const onChain = useOnChainAccountInformationQuery(address) + const rekeyed = useRekeyedAddressesQuery(address) + const { usdToPreferred } = useCurrency() + + const assetIds = useMemo( + () => (onChain.data?.assets ?? []).map(a => String(a.assetId)), + [onChain.data], + ) + + const { data: assets } = useAssetsQuery(assetIds) + const priceIds = useMemo( + () => [ALGO_ASSET_ID, ...assetIds], + [assetIds], + ) + const { data: prices } = useAssetPricesQuery(priceIds) + + const preview = useMemo(() => { + if (!onChain.data) return undefined + + const algoBalance = microAlgosToAlgos(onChain.data.amount) + const algoUsdPrice = + prices?.get(ALGO_ASSET_ID)?.usdPrice ?? new Decimal(0) + + const previewAssets: LedgerAccountPreviewAsset[] = [] + let totalUsd = algoBalance.times(algoUsdPrice) + + previewAssets.push({ + assetId: ALGO_ASSET_ID, + name: ALGO_ASSET.name ?? 'Algo', + unitName: ALGO_ASSET.unitName ?? 'ALGO', + amount: algoBalance, + fiatValue: usdToPreferred(algoBalance.times(algoUsdPrice)), + verificationTier: PeraAssetVerificationTier.verified, + logo: undefined, + isAlgo: true, + }) + + for (const holding of onChain.data.assets) { + const id = String(holding.assetId) + const meta = assets?.get(id) + const decimals = meta?.decimals ?? 0 + const amount = baseUnitsToDisplayUnits(holding.amount, decimals) + const usdPrice = prices?.get(id)?.usdPrice ?? new Decimal(0) + const usdValue = amount.times(usdPrice) + totalUsd = totalUsd.plus(usdValue) + previewAssets.push({ + assetId: id, + name: meta?.name ?? id, + unitName: meta?.unitName ?? '', + amount, + fiatValue: usdToPreferred(usdValue), + verificationTier: + meta?.peraMetadata?.verificationTier ?? + PeraAssetVerificationTier.unverified, + logo: meta?.peraMetadata?.logo ?? undefined, + isAlgo: false, + }) + } + + const authAddress = onChain.data.authAddress + let rekey: LedgerAccountRekeyRelationship = { kind: 'none' } + if (authAddress && authAddress !== address) { + rekey = { kind: 'rekeyedTo', authAddress } + } else if ( + !rekeyed.isError && + rekeyed.data && + rekeyed.data.length > 0 + ) { + rekey = { kind: 'canSignFor', addresses: rekeyed.data } + } + + return { + address, + algoBalance, + totalFiatValue: usdToPreferred(totalUsd), + assets: previewAssets, + rekey, + } + }, [ + address, + onChain.data, + assets, + prices, + rekeyed.data, + rekeyed.isError, + usdToPreferred, + ]) + + return { + preview, + isLoading: onChain.isLoading, + isError: onChain.isError, + refetch: onChain.refetch, + } +} diff --git a/packages/accounts/src/models/index.ts b/packages/accounts/src/models/index.ts index aa84539b3..271b64760 100644 --- a/packages/accounts/src/models/index.ts +++ b/packages/accounts/src/models/index.ts @@ -15,6 +15,7 @@ import type { BaseStoreState, Nullable } from '@perawallet/wallet-core-shared' export * from './accounts' export * from './balances' +export * from './ledger-account-preview' export type AccountsState = BaseStoreState & { accounts: WalletAccount[] diff --git a/packages/accounts/src/models/ledger-account-preview.ts b/packages/accounts/src/models/ledger-account-preview.ts new file mode 100644 index 000000000..766367716 --- /dev/null +++ b/packages/accounts/src/models/ledger-account-preview.ts @@ -0,0 +1,50 @@ +/* + 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 type { Decimal } from 'decimal.js' +import type { PeraAssetVerificationTier } from '@perawallet/wallet-core-assets' + +export type LedgerAccountPreviewAsset = { + assetId: string + name: string + unitName: string + /** Holding amount in display units */ + amount: Decimal + /** Holding value in the user's preferred currency */ + fiatValue: Decimal + verificationTier: PeraAssetVerificationTier + logo?: string + isAlgo: boolean +} + +export type LedgerAccountRekeyRelationship = + | { kind: 'rekeyedTo'; authAddress: string } + | { kind: 'canSignFor'; addresses: string[] } + | { kind: 'none' } + +export type LedgerAccountPreview = { + address: string + /** ALGO balance in display units */ + algoBalance: Decimal + /** Total account value in the user's preferred currency */ + totalFiatValue: Decimal + /** ALGO first, then assets ordered as `useSortedAssetBalances` orders them */ + assets: LedgerAccountPreviewAsset[] + rekey: LedgerAccountRekeyRelationship +} + +export type UseLedgerAccountPreviewResult = { + preview?: LedgerAccountPreview + isLoading: boolean + isError: boolean + refetch: () => void +} From 36390b21e9507dda9a0234099214ba9ff9cbed21 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 18 May 2026 12:49:51 +0100 Subject: [PATCH 05/39] test(accounts): cover missing-asset-metadata fallback in useLedgerAccountPreview --- .../__tests__/useLedgerAccountPreview.test.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/accounts/src/hooks/__tests__/useLedgerAccountPreview.test.ts b/packages/accounts/src/hooks/__tests__/useLedgerAccountPreview.test.ts index 6a7608a29..ae07be28d 100644 --- a/packages/accounts/src/hooks/__tests__/useLedgerAccountPreview.test.ts +++ b/packages/accounts/src/hooks/__tests__/useLedgerAccountPreview.test.ts @@ -140,6 +140,39 @@ describe('useLedgerAccountPreview', () => { expect(usdc?.name).toBe('USDC') }) + it('falls back to asset-id string, empty unitName, unverified tier and decimals=0 when asset metadata is missing', () => { + // Arrange + mocks.useOnChainAccountInformationQuery.mockReturnValue({ + data: { + address: 'ADDR', + amount: 0n, + minBalance: 0n, + status: 'Offline', + rewards: 0n, + assets: [{ assetId: 99999999n, amount: 42n, isFrozen: false }], + }, + isLoading: false, + isError: false, + refetch: vi.fn(), + }) + // useAssetsQuery returns an empty Map (beforeEach default) — no metadata for 99999999 + // useAssetPricesQuery default only has ALGO id '0' — no price for 99999999 → fiat 0 + + // Act + const { result } = renderHook(() => useLedgerAccountPreview('ADDR')) + + // Assert + const asa = result.current.preview?.assets.find( + a => a.assetId === '99999999', + ) + expect(asa?.name).toBe('99999999') + expect(asa?.unitName).toBe('') + expect(asa?.verificationTier).toBe('unverified') + expect(asa?.amount.toString()).toBe('42') + expect(asa?.fiatValue.toString()).toBe('0') + expect(asa?.isAlgo).toBe(false) + }) + it('reports rekeyedTo when the account is rekeyed', () => { mocks.useOnChainAccountInformationQuery.mockReturnValue({ data: { From 0845b32da9a4e503bc9573331783c93c420e980d Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 18 May 2026 12:51:44 +0100 Subject: [PATCH 06/39] feat(accounts): add prefetchLedgerAccountPreview util --- .../prefetchLedgerAccountPreview.test.ts | 90 +++++++++++++++++++ packages/accounts/src/hooks/index.ts | 1 + .../src/hooks/prefetchLedgerAccountPreview.ts | 46 ++++++++++ 3 files changed, 137 insertions(+) create mode 100644 packages/accounts/src/hooks/__tests__/prefetchLedgerAccountPreview.test.ts create mode 100644 packages/accounts/src/hooks/prefetchLedgerAccountPreview.ts diff --git a/packages/accounts/src/hooks/__tests__/prefetchLedgerAccountPreview.test.ts b/packages/accounts/src/hooks/__tests__/prefetchLedgerAccountPreview.test.ts new file mode 100644 index 000000000..0d7c55717 --- /dev/null +++ b/packages/accounts/src/hooks/__tests__/prefetchLedgerAccountPreview.test.ts @@ -0,0 +1,90 @@ +/* + 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 { QueryClient } from '@tanstack/react-query' +import { prefetchLedgerAccountPreview } from '../prefetchLedgerAccountPreview' +import { + getOnChainAccountInformationQueryKey, + getRekeyedAddressesQueryKey, +} from '../querykeys' + +const mocks = vi.hoisted(() => ({ + fetchOnChainAccountInformation: vi.fn(), + fetchRekeyedAddresses: vi.fn(), +})) + +// Paths are relative to THIS test file (hooks/__tests__/). The util lives in +// hooks/ and imports './endpoints' and '../account-discovery'. +vi.mock('../endpoints', () => ({ + fetchOnChainAccountInformation: mocks.fetchOnChainAccountInformation, +})) +vi.mock('../../account-discovery', () => ({ + fetchRekeyedAddresses: mocks.fetchRekeyedAddresses, +})) + +describe('prefetchLedgerAccountPreview', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.fetchOnChainAccountInformation.mockResolvedValue({ + address: 'ADDR', + amount: 0n, + minBalance: 0n, + status: 'Offline', + rewards: 0n, + assets: [], + }) + mocks.fetchRekeyedAddresses.mockResolvedValue([]) + }) + + it('primes the on-chain info and rekeyed-addresses query caches', async () => { + const queryClient = new QueryClient() + const algokit = {} as never + + await prefetchLedgerAccountPreview( + queryClient, + algokit, + 'ADDR', + 'mainnet', + ) + + expect( + queryClient.getQueryData( + getOnChainAccountInformationQueryKey('ADDR', 'mainnet'), + ), + ).toBeDefined() + expect( + queryClient.getQueryData( + getRekeyedAddressesQueryKey('ADDR', 'mainnet'), + ), + ).toBeDefined() + expect(mocks.fetchOnChainAccountInformation).toHaveBeenCalledWith( + algokit, + 'ADDR', + ) + expect(mocks.fetchRekeyedAddresses).toHaveBeenCalledWith('ADDR') + }) + + it('never rejects when a fetch fails (best-effort)', async () => { + mocks.fetchRekeyedAddresses.mockRejectedValue(new Error('network')) + const queryClient = new QueryClient() + + await expect( + prefetchLedgerAccountPreview( + queryClient, + {} as never, + 'ADDR', + 'mainnet', + ), + ).resolves.toBeUndefined() + }) +}) diff --git a/packages/accounts/src/hooks/index.ts b/packages/accounts/src/hooks/index.ts index ed747d544..b9c77b142 100644 --- a/packages/accounts/src/hooks/index.ts +++ b/packages/accounts/src/hooks/index.ts @@ -42,6 +42,7 @@ export * from './useAccountLogicalType' export * from './useRekeyTransition' export * from './useRekeyedAddressesQuery' export * from './useLedgerAccountPreview' +export * from './prefetchLedgerAccountPreview' export * from './useOwnedAssets' export * from './useHDImportSession' export { invalidateAccountQueries, isAccountQuery } from './querykeys' diff --git a/packages/accounts/src/hooks/prefetchLedgerAccountPreview.ts b/packages/accounts/src/hooks/prefetchLedgerAccountPreview.ts new file mode 100644 index 000000000..8ec0427ca --- /dev/null +++ b/packages/accounts/src/hooks/prefetchLedgerAccountPreview.ts @@ -0,0 +1,46 @@ +/* + 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 type { QueryClient } from '@tanstack/react-query' +import type { AlgorandClient } from '@algorandfoundation/algokit-utils' +import type { Network } from '@perawallet/wallet-core-shared' +import { fetchRekeyedAddresses } from '../account-discovery' +import { fetchOnChainAccountInformation } from './endpoints' +import { + getOnChainAccountInformationQueryKey, + getRekeyedAddressesQueryKey, +} from './querykeys' + +/** + * Best-effort warm-up of the two address-bound network queries the Ledger + * account info sheet reads. Never throws — prefetch failures must not block + * the discovery / selection / import flow. + */ +export const prefetchLedgerAccountPreview = async ( + queryClient: QueryClient, + algokit: AlgorandClient, + address: string, + network: Network, +): Promise => { + if (!address) return + + await Promise.allSettled([ + queryClient.prefetchQuery({ + queryKey: getOnChainAccountInformationQueryKey(address, network), + queryFn: () => fetchOnChainAccountInformation(algokit, address), + }), + queryClient.prefetchQuery({ + queryKey: getRekeyedAddressesQueryKey(address, network), + queryFn: () => fetchRekeyedAddresses(address), + }), + ]) +} From d595e0b883ae3aa2fcc823da48acb0cf1f17c502 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 18 May 2026 12:56:10 +0100 Subject: [PATCH 07/39] test(accounts): disable query retries in prefetchLedgerAccountPreview test --- .../src/hooks/__tests__/prefetchLedgerAccountPreview.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/accounts/src/hooks/__tests__/prefetchLedgerAccountPreview.test.ts b/packages/accounts/src/hooks/__tests__/prefetchLedgerAccountPreview.test.ts index 0d7c55717..eded24c18 100644 --- a/packages/accounts/src/hooks/__tests__/prefetchLedgerAccountPreview.test.ts +++ b/packages/accounts/src/hooks/__tests__/prefetchLedgerAccountPreview.test.ts @@ -47,7 +47,7 @@ describe('prefetchLedgerAccountPreview', () => { }) it('primes the on-chain info and rekeyed-addresses query caches', async () => { - const queryClient = new QueryClient() + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) const algokit = {} as never await prefetchLedgerAccountPreview( @@ -76,7 +76,7 @@ describe('prefetchLedgerAccountPreview', () => { it('never rejects when a fetch fails (best-effort)', async () => { mocks.fetchRekeyedAddresses.mockRejectedValue(new Error('network')) - const queryClient = new QueryClient() + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) await expect( prefetchLedgerAccountPreview( From efdcdfd428ecdd3172ecdd154216ebd7eb56076a Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 18 May 2026 12:59:13 +0100 Subject: [PATCH 08/39] feat(ledger): add useLedgerAccountInfoContent presentation hook --- .../useLedgerAccountInfoContent.spec.ts | 138 ++++++++++++++++++ .../useLedgerAccountInfoContent.ts | 116 +++++++++++++++ 2 files changed, 254 insertions(+) create mode 100644 apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/__tests__/useLedgerAccountInfoContent.spec.ts create mode 100644 apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/useLedgerAccountInfoContent.ts diff --git a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/__tests__/useLedgerAccountInfoContent.spec.ts b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/__tests__/useLedgerAccountInfoContent.spec.ts new file mode 100644 index 000000000..f869cd94c --- /dev/null +++ b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/__tests__/useLedgerAccountInfoContent.spec.ts @@ -0,0 +1,138 @@ +/* + 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 } from '@testing-library/react' +import { Decimal } from 'decimal.js' +import { useLedgerAccountInfoContent } from '../useLedgerAccountInfoContent' + +const mocks = vi.hoisted(() => ({ useLedgerAccountPreview: vi.fn() })) + +vi.mock('@perawallet/wallet-core-accounts', () => ({ + useLedgerAccountPreview: mocks.useLedgerAccountPreview, +})) +vi.mock('@hooks/useLanguage', () => ({ + useLanguage: () => ({ t: (k: string) => k }), +})) + +const baseAsset = { + assetId: '0', + name: 'Algo', + unitName: 'ALGO', + amount: new Decimal(5), + fiatValue: new Decimal(10), + verificationTier: 'verified', + isAlgo: true, +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('useLedgerAccountInfoContent', () => { + it('builds the title from the ledger account index', () => { + mocks.useLedgerAccountPreview.mockReturnValue({ + preview: undefined, + isLoading: true, + isError: false, + refetch: vi.fn(), + }) + + const { result } = renderHook(() => + useLedgerAccountInfoContent('ADDR', 0), + ) + + expect(result.current.title).toBe('Ledger #0') + expect(result.current.isLoading).toBe(true) + expect(result.current.items).toEqual([]) + }) + + it('emits account-details and asset rows, no rekey section when none', () => { + mocks.useLedgerAccountPreview.mockReturnValue({ + preview: { + address: 'ADDR', + algoBalance: new Decimal(5), + totalFiatValue: new Decimal(10), + assets: [baseAsset], + rekey: { kind: 'none' }, + }, + isLoading: false, + isError: false, + refetch: vi.fn(), + }) + + const { result } = renderHook(() => + useLedgerAccountInfoContent('ADDR', 3), + ) + + const kinds = result.current.items.map(i => i.kind) + expect(kinds).toEqual([ + 'sectionHeader', + 'account', + 'sectionHeader', + 'asset', + ]) + expect(result.current.title).toBe('Ledger #3') + }) + + it('emits a "can be signed by" rekey section when rekeyedTo', () => { + mocks.useLedgerAccountPreview.mockReturnValue({ + preview: { + address: 'ADDR', + algoBalance: new Decimal(0), + totalFiatValue: new Decimal(0), + assets: [baseAsset], + rekey: { kind: 'rekeyedTo', authAddress: 'AUTH' }, + }, + isLoading: false, + isError: false, + refetch: vi.fn(), + }) + + const { result } = renderHook(() => + useLedgerAccountInfoContent('ADDR', 1), + ) + + const rekeyAddressItems = result.current.items.filter( + i => i.kind === 'rekeyAddress', + ) + expect(rekeyAddressItems).toHaveLength(1) + expect(rekeyAddressItems[0]).toMatchObject({ + kind: 'rekeyAddress', + address: 'AUTH', + }) + }) + + it('emits a "can sign for these" rekey section when canSignFor', () => { + mocks.useLedgerAccountPreview.mockReturnValue({ + preview: { + address: 'ADDR', + algoBalance: new Decimal(0), + totalFiatValue: new Decimal(0), + assets: [baseAsset], + rekey: { kind: 'canSignFor', addresses: ['R1', 'R2'] }, + }, + isLoading: false, + isError: false, + refetch: vi.fn(), + }) + + const { result } = renderHook(() => + useLedgerAccountInfoContent('ADDR', 1), + ) + + const rekeyAddresses = result.current.items + .filter(i => i.kind === 'rekeyAddress') + .map(i => (i.kind === 'rekeyAddress' ? i.address : '')) + expect(rekeyAddresses).toEqual(['R1', 'R2']) + }) +}) diff --git a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/useLedgerAccountInfoContent.ts b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/useLedgerAccountInfoContent.ts new file mode 100644 index 000000000..622301557 --- /dev/null +++ b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/useLedgerAccountInfoContent.ts @@ -0,0 +1,116 @@ +/* + 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 { + useLedgerAccountPreview, + type LedgerAccountPreviewAsset, +} from '@perawallet/wallet-core-accounts' +import { useLanguage } from '@hooks/useLanguage' + +export type LedgerInfoListItem = + | { kind: 'sectionHeader'; key: string; title: string } + | { + kind: 'account' + key: string + address: string + algoBalance: string + fiatValue: string + } + | { kind: 'asset'; key: string; asset: LedgerAccountPreviewAsset } + | { kind: 'rekeyAddress'; key: string; address: string } + +type UseLedgerAccountInfoContentResult = { + title: string + items: LedgerInfoListItem[] + isLoading: boolean + isError: boolean + refetch: () => void +} + +export const useLedgerAccountInfoContent = ( + address: string, + accountIndex: number, +): UseLedgerAccountInfoContentResult => { + const { t } = useLanguage() + const { preview, isLoading, isError, refetch } = + useLedgerAccountPreview(address) + + const items = useMemo(() => { + if (!preview) return [] + + const list: LedgerInfoListItem[] = [ + { + kind: 'sectionHeader', + key: 'h-details', + title: t('ledger.account_info.account_details'), + }, + { + kind: 'account', + key: 'account', + address: preview.address, + algoBalance: preview.algoBalance.toString(), + fiatValue: preview.totalFiatValue.toString(), + }, + { + kind: 'sectionHeader', + key: 'h-assets', + title: t('ledger.account_info.assets'), + }, + ...preview.assets.map( + (asset): LedgerInfoListItem => ({ + kind: 'asset', + key: `asset-${asset.assetId}`, + asset, + }), + ), + ] + + if (preview.rekey.kind === 'rekeyedTo') { + list.push( + { + kind: 'sectionHeader', + key: 'h-rekey', + title: t('ledger.account_info.can_be_signed_by'), + }, + { + kind: 'rekeyAddress', + key: `rekey-${preview.rekey.authAddress}`, + address: preview.rekey.authAddress, + }, + ) + } else if (preview.rekey.kind === 'canSignFor') { + list.push({ + kind: 'sectionHeader', + key: 'h-rekey', + title: t('ledger.account_info.can_sign_for'), + }) + preview.rekey.addresses.forEach(addr => { + list.push({ + kind: 'rekeyAddress', + key: `rekey-${addr}`, + address: addr, + }) + }) + } + + return list + }, [preview, t]) + + return { + title: `Ledger #${accountIndex}`, + items, + isLoading, + isError, + refetch, + } +} From 302d4855436ae64bc3b393eab46cc74020b3eef2 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 18 May 2026 13:04:25 +0100 Subject: [PATCH 09/39] refactor(ledger): carry Decimal through useLedgerAccountInfoContent (defer formatting to display) --- .../useLedgerAccountInfoContent.spec.ts | 28 +++++++++++++++++++ .../useLedgerAccountInfoContent.ts | 9 +++--- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/__tests__/useLedgerAccountInfoContent.spec.ts b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/__tests__/useLedgerAccountInfoContent.spec.ts index f869cd94c..48ddd3d75 100644 --- a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/__tests__/useLedgerAccountInfoContent.spec.ts +++ b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/__tests__/useLedgerAccountInfoContent.spec.ts @@ -135,4 +135,32 @@ describe('useLedgerAccountInfoContent', () => { .map(i => (i.kind === 'rekeyAddress' ? i.address : '')) expect(rekeyAddresses).toEqual(['R1', 'R2']) }) + + it('carries Decimal instances for algoBalance and fiatValue on the account item', () => { + mocks.useLedgerAccountPreview.mockReturnValue({ + preview: { + address: 'ADDR', + algoBalance: new Decimal('408.2'), + totalFiatValue: new Decimal('48.45'), + assets: [baseAsset], + rekey: { kind: 'none' }, + }, + isLoading: false, + isError: false, + refetch: vi.fn(), + }) + + const { result } = renderHook(() => + useLedgerAccountInfoContent('ADDR', 2), + ) + + const acct = result.current.items.find(i => i.kind === 'account') + expect(acct?.kind).toBe('account') + if (acct?.kind === 'account') { + expect(acct.algoBalance).toBeInstanceOf(Decimal) + expect(acct.algoBalance.toString()).toBe('408.2') + expect(acct.fiatValue).toBeInstanceOf(Decimal) + expect(acct.fiatValue.toString()).toBe('48.45') + } + }) }) diff --git a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/useLedgerAccountInfoContent.ts b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/useLedgerAccountInfoContent.ts index 622301557..9c728ca7c 100644 --- a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/useLedgerAccountInfoContent.ts +++ b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/useLedgerAccountInfoContent.ts @@ -11,6 +11,7 @@ */ import { useMemo } from 'react' +import type { Decimal } from 'decimal.js' import { useLedgerAccountPreview, type LedgerAccountPreviewAsset, @@ -23,8 +24,8 @@ export type LedgerInfoListItem = kind: 'account' key: string address: string - algoBalance: string - fiatValue: string + algoBalance: Decimal + fiatValue: Decimal } | { kind: 'asset'; key: string; asset: LedgerAccountPreviewAsset } | { kind: 'rekeyAddress'; key: string; address: string } @@ -58,8 +59,8 @@ export const useLedgerAccountInfoContent = ( kind: 'account', key: 'account', address: preview.address, - algoBalance: preview.algoBalance.toString(), - fiatValue: preview.totalFiatValue.toString(), + algoBalance: preview.algoBalance, + fiatValue: preview.totalFiatValue, }, { kind: 'sectionHeader', From 1fbee7d972f8e9ae6ada2830abe1b64f94b86717 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 18 May 2026 13:16:27 +0100 Subject: [PATCH 10/39] feat(ledger): add LedgerAccountInfoContent bottom-sheet component --- apps/mobile/src/i18n/locales/en.json | 9 + .../LedgerAccountInfoContent.tsx | 134 ++++++++++++++ .../LedgerAccountInfoRows.tsx | 172 ++++++++++++++++++ .../LedgerAccountInfoContent/index.ts | 14 ++ .../LedgerAccountInfoContent/styles.ts | 50 +++++ 5 files changed, 379 insertions(+) create mode 100644 apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoContent.tsx create mode 100644 apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoRows.tsx create mode 100644 apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/index.ts create mode 100644 apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/styles.ts diff --git a/apps/mobile/src/i18n/locales/en.json b/apps/mobile/src/i18n/locales/en.json index 29ec6980f..23edab48c 100644 --- a/apps/mobile/src/i18n/locales/en.json +++ b/apps/mobile/src/i18n/locales/en.json @@ -2132,6 +2132,15 @@ "no_new_accounts": "All accounts on this device are already imported.", "find_another_wallet": "Find another account" }, + "account_info": { + "account_details": "Account details", + "assets": "Assets", + "ledger_account_label": "Ledger Account", + "can_be_signed_by": "Can be signed by", + "can_sign_for": "Can sign for these", + "error": "Couldn't load account information.", + "retry": "Try again" + }, "verify": { "title": "Verify Account Addresses via Ledger", "description": "The following account addresses will display on your Ledger device. Please carefully compare and verify each address in order to finalize device pairing.", diff --git a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoContent.tsx b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoContent.tsx new file mode 100644 index 000000000..e794aad9c --- /dev/null +++ b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoContent.tsx @@ -0,0 +1,134 @@ +/* + 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 { + PWView, + PWText, + PWToolbar, + PWIcon, + PWButton, + PWFlatList, +} from '@components/core' +import { useLanguage } from '@hooks/useLanguage' +import { useBottomSheetResult } from '@modules/bottom-sheet' +import { useCurrency } from '@perawallet/wallet-core-currencies' +import { + useLedgerAccountInfoContent, + type LedgerInfoListItem, +} from './useLedgerAccountInfoContent' +import { + LedgerSectionHeaderRow, + LedgerAccountDetailsRow, + LedgerAssetRow, + LedgerRekeyAddressRow, +} from './LedgerAccountInfoRows' +import { useStyles } from './styles' + +export type LedgerAccountInfoContentProps = { + address: string + accountIndex: number +} + +export const LedgerAccountInfoContent = ({ + address, + accountIndex, +}: LedgerAccountInfoContentProps) => { + const styles = useStyles() + const { t } = useLanguage() + const { dismiss } = useBottomSheetResult() + const { preferredCurrency } = useCurrency() + const { title, items, isLoading, isError, refetch } = + useLedgerAccountInfoContent(address, accountIndex) + + const renderItem = ({ item }: { item: LedgerInfoListItem }) => { + switch (item.kind) { + case 'sectionHeader': + return + case 'account': + return ( + + ) + case 'asset': + return ( + + ) + case 'rekeyAddress': + return + } + } + + return ( + + + } + center={{title}} + /> + + {isLoading && ( + + + + )} + + {isError && !isLoading && ( + + + {t('ledger.account_info.error')} + + + + )} + + {!isLoading && !isError && ( + item.key} + contentContainerStyle={styles.listContent} + showsVerticalScrollIndicator={false} + testID='ledger_account_info_list' + /> + )} + + ) +} diff --git a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoRows.tsx b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoRows.tsx new file mode 100644 index 000000000..076cb3785 --- /dev/null +++ b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoRows.tsx @@ -0,0 +1,172 @@ +/* + 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 { PWView, PWText } from '@components/core' +import { CurrencyDisplay } from '@components/CurrencyDisplay' +import { truncateAlgorandAddress } from '@perawallet/wallet-core-shared' +import { ALGO_ASSET } from '@perawallet/wallet-core-assets' +import { AssetIcon } from '@modules/assets/components/AssetIcon' +import { AssetTierChip } from '@modules/assets/components/AssetTierChip' +import LightLedgerAccountIcon from '@assets/icons/accounts/light/ledger-account.svg' +import type { Decimal } from 'decimal.js' +import type { LedgerInfoListItem } from './useLedgerAccountInfoContent' +import { useStyles } from './styles' + +type LedgerAccountPreviewAsset = Extract< + LedgerInfoListItem, + { kind: 'asset' } +>['asset'] + +export const LedgerSectionHeaderRow = ({ title }: { title: string }) => { + const styles = useStyles() + return ( + + {title} + + ) +} + +type AccountRowProps = { + address: string + algoBalance: Decimal + fiatValue: Decimal + label: string + preferredCurrency: string +} + +export const LedgerAccountDetailsRow = ({ + address, + algoBalance, + fiatValue, + label, + preferredCurrency, +}: AccountRowProps) => { + const styles = useStyles() + return ( + + + + + {truncateAlgorandAddress(address)} + + + {label} + + + + + + + + ) +} + +export const LedgerAssetRow = ({ + asset, + preferredCurrency, +}: { + asset: LedgerAccountPreviewAsset + preferredCurrency: string +}) => { + const styles = useStyles() + const iconAsset = asset.isAlgo + ? ALGO_ASSET + : { + assetId: asset.assetId, + name: asset.name, + unitName: asset.unitName, + decimals: 0, + } + return ( + + + + + {asset.name} + + + + + + + + + ) +} + +export const LedgerRekeyAddressRow = ({ address }: { address: string }) => { + const styles = useStyles() + return ( + + + + {truncateAlgorandAddress(address)} + + + + ) +} diff --git a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/index.ts b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/index.ts new file mode 100644 index 000000000..56cc0b838 --- /dev/null +++ b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/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 { LedgerAccountInfoContent } from './LedgerAccountInfoContent' +export type { LedgerAccountInfoContentProps } from './LedgerAccountInfoContent' diff --git a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/styles.ts b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/styles.ts new file mode 100644 index 000000000..985b120e3 --- /dev/null +++ b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/styles.ts @@ -0,0 +1,50 @@ +/* + 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' + +export const useStyles = makeStyles(theme => ({ + container: { + flex: 1, + }, + listContent: { + paddingHorizontal: theme.spacing.xl, + paddingBottom: theme.spacing.xl, + }, + sectionHeader: { + marginTop: theme.spacing.lg, + marginBottom: theme.spacing.sm, + color: theme.colors.textMain, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: theme.spacing.md, + gap: theme.spacing.md, + }, + rowText: { + flex: 1, + gap: theme.spacing.xxs, + }, + rowTrailing: { + alignItems: 'flex-end', + gap: theme.spacing.xxs, + }, + secondary: { + color: theme.colors.textGray, + }, + centerState: { + paddingVertical: theme.spacing['4xl'], + alignItems: 'center', + gap: theme.spacing.md, + }, +})) From 91031079141122863ecacce98aa8bffa263422c9 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 18 May 2026 13:20:06 +0100 Subject: [PATCH 11/39] refactor(ledger): import LedgerAccountPreviewAsset from package, drop Extract workaround --- .../LedgerAccountInfoContent/LedgerAccountInfoRows.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoRows.tsx b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoRows.tsx index 076cb3785..7cb65551d 100644 --- a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoRows.tsx +++ b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoRows.tsx @@ -18,14 +18,9 @@ import { AssetIcon } from '@modules/assets/components/AssetIcon' import { AssetTierChip } from '@modules/assets/components/AssetTierChip' import LightLedgerAccountIcon from '@assets/icons/accounts/light/ledger-account.svg' import type { Decimal } from 'decimal.js' -import type { LedgerInfoListItem } from './useLedgerAccountInfoContent' +import type { LedgerAccountPreviewAsset } from '@perawallet/wallet-core-accounts' import { useStyles } from './styles' -type LedgerAccountPreviewAsset = Extract< - LedgerInfoListItem, - { kind: 'asset' } ->['asset'] - export const LedgerSectionHeaderRow = ({ title }: { title: string }) => { const styles = useStyles() return ( From 9d16bbc622f48c7a55457ca57016af1994068489 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 18 May 2026 13:27:23 +0100 Subject: [PATCH 12/39] style(ledger): fix import order and memoize renderItem in LedgerAccountInfoContent --- .../LedgerAccountInfoContent/LedgerAccountInfoContent.tsx | 7 ++++--- .../LedgerAccountInfoContent/LedgerAccountInfoRows.tsx | 8 ++++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoContent.tsx b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoContent.tsx index e794aad9c..5a4cec097 100644 --- a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoContent.tsx +++ b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoContent.tsx @@ -10,7 +10,9 @@ limitations under the License */ +import { useCallback } from 'react' import { ActivityIndicator } from 'react-native' +import { useCurrency } from '@perawallet/wallet-core-currencies' import { PWView, PWText, @@ -21,7 +23,6 @@ import { } from '@components/core' import { useLanguage } from '@hooks/useLanguage' import { useBottomSheetResult } from '@modules/bottom-sheet' -import { useCurrency } from '@perawallet/wallet-core-currencies' import { useLedgerAccountInfoContent, type LedgerInfoListItem, @@ -50,7 +51,7 @@ export const LedgerAccountInfoContent = ({ const { title, items, isLoading, isError, refetch } = useLedgerAccountInfoContent(address, accountIndex) - const renderItem = ({ item }: { item: LedgerInfoListItem }) => { + const renderItem = useCallback(({ item }: { item: LedgerInfoListItem }) => { switch (item.kind) { case 'sectionHeader': return @@ -74,7 +75,7 @@ export const LedgerAccountInfoContent = ({ case 'rekeyAddress': return } - } + }, [t, preferredCurrency]) return ( diff --git a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoRows.tsx b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoRows.tsx index 7cb65551d..2917163d4 100644 --- a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoRows.tsx +++ b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoRows.tsx @@ -10,15 +10,15 @@ limitations under the License */ -import { PWView, PWText } from '@components/core' -import { CurrencyDisplay } from '@components/CurrencyDisplay' +import type { Decimal } from 'decimal.js' import { truncateAlgorandAddress } from '@perawallet/wallet-core-shared' import { ALGO_ASSET } from '@perawallet/wallet-core-assets' +import type { LedgerAccountPreviewAsset } from '@perawallet/wallet-core-accounts' +import { PWView, PWText } from '@components/core' +import { CurrencyDisplay } from '@components/CurrencyDisplay' import { AssetIcon } from '@modules/assets/components/AssetIcon' import { AssetTierChip } from '@modules/assets/components/AssetTierChip' import LightLedgerAccountIcon from '@assets/icons/accounts/light/ledger-account.svg' -import type { Decimal } from 'decimal.js' -import type { LedgerAccountPreviewAsset } from '@perawallet/wallet-core-accounts' import { useStyles } from './styles' export const LedgerSectionHeaderRow = ({ title }: { title: string }) => { From 7381742a80bf9bd9da4e73117ad97d64afcbbd32 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 18 May 2026 13:32:23 +0100 Subject: [PATCH 13/39] feat(ledger): native-parity row layout, info affordance, preview prefetch --- .../LedgerAccountSelectionRow.tsx | 34 +++++--- .../LedgerSelectAccountsScreen.tsx | 3 + .../useLedgerSelectAccountsScreen.spec.ts | 77 +++++++++++++++++++ ...n.ts => useLedgerSelectAccountsScreen.tsx} | 44 ++++++++++- 4 files changed, 147 insertions(+), 11 deletions(-) rename apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/{useLedgerSelectAccountsScreen.ts => useLedgerSelectAccountsScreen.tsx} (83%) diff --git a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/LedgerAccountSelectionRow/LedgerAccountSelectionRow.tsx b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/LedgerAccountSelectionRow/LedgerAccountSelectionRow.tsx index 09ec3a039..e5cdf9a0f 100644 --- a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/LedgerAccountSelectionRow/LedgerAccountSelectionRow.tsx +++ b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/LedgerAccountSelectionRow/LedgerAccountSelectionRow.tsx @@ -19,25 +19,29 @@ import { PWChip, PWIcon, } from '@components/core' +import { truncateAlgorandAddress } from '@perawallet/wallet-core-shared' import { useClipboard } from '@hooks/useClipboard' import { useLanguage } from '@hooks/useLanguage' -import { truncateAlgorandAddress } from '@perawallet/wallet-core-shared' import LightLedgerAccountIcon from '@assets/icons/accounts/light/ledger-account.svg' import { useStyles } from './styles' export type LedgerAccountSelectionRowProps = { address: string + accountIndex: number isSelected: boolean isImported: boolean onToggle: () => void + onInfoPress: (address: string, accountIndex: number) => void testID?: string } export const LedgerAccountSelectionRow = ({ address, + accountIndex, isSelected, isImported, onToggle, + onInfoPress, testID, }: LedgerAccountSelectionRowProps) => { const styles = useStyles({ isImported }) @@ -48,6 +52,10 @@ export const LedgerAccountSelectionRow = ({ void copyToClipboard(address) }, [copyToClipboard, address]) + const handleInfoPress = useCallback(() => { + onInfoPress(address, accountIndex) + }, [onInfoPress, address, accountIndex]) + return ( + {!isImported && ( + + )} + - {!isImported && ( - - )} + ) } diff --git a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/LedgerSelectAccountsScreen.tsx b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/LedgerSelectAccountsScreen.tsx index 55fd02397..5cad31036 100644 --- a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/LedgerSelectAccountsScreen.tsx +++ b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/LedgerSelectAccountsScreen.tsx @@ -43,6 +43,7 @@ export const LedgerSelectAccountsScreen = () => { toggleSelectAll, handleContinue, handleFindAnother, + handleInfoPress, } = useLedgerSelectAccountsScreen() const showSelectAll = accounts.length > 1 && !areAllImported @@ -53,9 +54,11 @@ export const LedgerSelectAccountsScreen = () => { return ( toggleSelection(item.address)} + onInfoPress={handleInfoPress} testID={`ledger_select_row_${item.address}`} /> ) 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 ef2f31914..61df5513e 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 @@ -78,8 +78,33 @@ vi.mock('@react-navigation/native', () => ({ }), })) +const { mockPrefetch, mockRequest, mockQueryClient } = vi.hoisted(() => { + const mockPrefetch = vi.fn().mockResolvedValue(undefined) + const mockRequest = vi.fn().mockResolvedValue(undefined) + const mockQueryClient = {} + return { mockPrefetch, mockRequest, mockQueryClient } +}) + +// useLedgerAccountPreview is included because the screen hook imports +// LedgerAccountInfoContent (whose hook chain references it) at module load; +// it is never invoked in these specs (the sheet content is not rendered). vi.mock('@perawallet/wallet-core-accounts', () => ({ useAllAccounts: () => [], + prefetchLedgerAccountPreview: mockPrefetch, + useLedgerAccountPreview: vi.fn(), +})) + +vi.mock('@modules/bottom-sheet', () => ({ + useBottomSheet: () => ({ request: mockRequest }), +})) + +vi.mock('@perawallet/wallet-core-blockchain', () => ({ + useAlgorandClient: () => ({}), + useNetwork: () => ({ network: 'mainnet' }), +})) + +vi.mock('@tanstack/react-query', () => ({ + useQueryClient: () => mockQueryClient, })) import { useLedgerSelectAccountsScreen } from '../useLedgerSelectAccountsScreen' @@ -108,6 +133,8 @@ describe('useLedgerSelectAccountsScreen', () => { mockConnect.mockReset() mockGetProviderRegistry.mockReset() mockErrorToast.mockReset() + mockPrefetch.mockClear() + mockRequest.mockClear() const transport = buildTransport() mockConnect.mockResolvedValue(transport) @@ -319,4 +346,54 @@ describe('useLedgerSelectAccountsScreen', () => { expect(mockDisconnectTransport).toHaveBeenCalledTimes(1) }) }) + + it('prefetches a preview for every route account on mount', async () => { + renderHook(() => useLedgerSelectAccountsScreen()) + + await waitFor(() => { + expect(mockPrefetch).toHaveBeenCalledWith( + mockQueryClient, + expect.anything(), + 'AAA111', + 'mainnet', + ) + expect(mockPrefetch).toHaveBeenCalledWith( + mockQueryClient, + expect.anything(), + 'BBB222', + 'mainnet', + ) + }) + }) + + it('opens the info bottom sheet with address and accountIndex', () => { + const { result } = renderHook(() => useLedgerSelectAccountsScreen()) + + act(() => { + result.current.handleInfoPress('AAA111', 0) + }) + + expect(mockRequest).toHaveBeenCalledTimes(1) + const arg = mockRequest.mock.calls[0][0] + expect(arg.options).toEqual({ size: 'lg' }) + expect(arg.contents).toBeTruthy() + }) + + it('prefetches a newly found account', async () => { + mockGetAddress.mockResolvedValueOnce(buildAccount(2, 'CCC333')) + const { result } = renderHook(() => useLedgerSelectAccountsScreen()) + + await act(async () => { + await result.current.handleFindAnother() + }) + + await waitFor(() => { + expect(mockPrefetch).toHaveBeenCalledWith( + mockQueryClient, + expect.anything(), + 'CCC333', + 'mainnet', + ) + }) + }) }) diff --git a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.ts b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.tsx similarity index 83% rename from apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.ts rename to apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.tsx index 4f8e6c321..b09eeb366 100644 --- a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.ts +++ b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.tsx @@ -10,10 +10,14 @@ limitations under the License */ -import { useState, useMemo, useCallback, useRef, useEffect } from 'react' +import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react' import { RouteProp, useRoute } from '@react-navigation/native' +import { useQueryClient } from '@tanstack/react-query' import { getProvider } from '@perawallet/wallet-extension-provider' -import { useAllAccounts } from '@perawallet/wallet-core-accounts' +import { + useAllAccounts, + prefetchLedgerAccountPreview, +} from '@perawallet/wallet-core-accounts' import type { LedgerAccount } from '@perawallet/wallet-core-ledger' import { LedgerProviderNotFoundError, @@ -21,9 +25,12 @@ import { } from '@perawallet/wallet-core-ledger' import type { HardwareWalletTransport } from '@perawallet/wallet-core-hardware-wallet' import type { Nullable } from '@perawallet/wallet-core-shared' +import { useAlgorandClient, useNetwork } from '@perawallet/wallet-core-blockchain' import { useAppNavigation } from '@hooks/useAppNavigation' import { useLanguage } from '@hooks/useLanguage' import { useToast } from '@hooks/useToast' +import { useBottomSheet } from '@modules/bottom-sheet' +import { LedgerAccountInfoContent } from '@modules/ledger/components/LedgerAccountInfoContent' import type { AddAccountStackParamList } from '@modules/onboarding/routes/types' import { getLedgerErrorPreset } from '@modules/ledger/utils' @@ -44,6 +51,7 @@ type UseLedgerSelectAccountsScreenResult = { toggleSelectAll: () => void handleContinue: () => void handleFindAnother: () => Promise + handleInfoPress: (address: string, accountIndex: number) => void t: (key: string, options?: Record) => string } @@ -62,6 +70,11 @@ export const useLedgerSelectAccountsScreen = const allAccounts = useAllAccounts() const { errorToast } = useToast() + const queryClient = useQueryClient() + const algokit = useAlgorandClient() + const { network } = useNetwork() + const { request } = useBottomSheet() + const [accounts, setAccounts] = useState(routeAccounts) const [isFetchingMore, setIsFetchingMore] = useState(false) const [selectedAddresses, setSelectedAddresses] = useState>( @@ -91,6 +104,17 @@ export const useLedgerSelectAccountsScreen = } }, []) + useEffect(() => { + accounts.forEach(acc => { + void prefetchLedgerAccountPreview( + queryClient, + algokit, + acc.address, + network, + ) + }) + }, [accounts, queryClient, algokit, network]) + const alreadyImportedAddresses = useMemo(() => { return new Set(allAccounts.map(acc => acc.address)) }, [allAccounts]) @@ -200,6 +224,21 @@ export const useLedgerSelectAccountsScreen = } }, [deviceId, transportType, errorToast, t]) + const handleInfoPress = useCallback( + (address: string, accountIndex: number) => { + void request({ + contents: ( + + ), + options: { size: 'lg' }, + }) + }, + [request], + ) + const areAllImported = newAccounts.length === 0 const canContinue = !isFetchingMore && (areAllImported || selectedAddresses.size > 0) @@ -216,6 +255,7 @@ export const useLedgerSelectAccountsScreen = toggleSelectAll, handleContinue, handleFindAnother, + handleInfoPress, t, } } From 82d402eeff76e339d0b97e8dd4ec4f3d8daf64c0 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 18 May 2026 13:40:51 +0100 Subject: [PATCH 14/39] fix(ledger): use PWTouchableIcon for tappable info and close icons --- .../LedgerAccountInfoContent/LedgerAccountInfoContent.tsx | 4 ++-- .../LedgerAccountSelectionRow/LedgerAccountSelectionRow.tsx | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoContent.tsx b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoContent.tsx index 5a4cec097..2c54866f3 100644 --- a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoContent.tsx +++ b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoContent.tsx @@ -17,7 +17,7 @@ import { PWView, PWText, PWToolbar, - PWIcon, + PWTouchableIcon, PWButton, PWFlatList, } from '@components/core' @@ -81,7 +81,7 @@ export const LedgerAccountInfoContent = ({ - Date: Mon, 18 May 2026 13:46:01 +0100 Subject: [PATCH 15/39] test(ledger): integration test for the account info sheet --- .../ledger-account-info-sheet.test.tsx | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 apps/mobile/src/__integration__/ledger-account-info-sheet.test.tsx diff --git a/apps/mobile/src/__integration__/ledger-account-info-sheet.test.tsx b/apps/mobile/src/__integration__/ledger-account-info-sheet.test.tsx new file mode 100644 index 000000000..5a73b1832 --- /dev/null +++ b/apps/mobile/src/__integration__/ledger-account-info-sheet.test.tsx @@ -0,0 +1,135 @@ +/* + 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 { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest' +import { fireEvent, screen, waitFor } from '@testing-library/react' + +// The ledger account SVG ships as a data-URI-encoded clip-path that JSDOM +// rejects with InvalidCharacterError when it tries to parse the attribute +// as an XML Name. Stub it out the same way vitest.integration-setup.ts +// stubs other SVGs that trigger this JSDOM limitation. +vi.mock('@assets/icons/accounts/light/ledger-account.svg', () => ({ + default: () => null, +})) + +import { server } from '@test-utils/msw-server' +import { renderWithNavigation } from '@test-utils/renderWithNavigation' +import { resetTestKeystore } from '@test-utils/algorand-keystore-test' +import { + resetTestDatabase, + seedAlgoAsset, + setupTestDatabase, + teardownTestDatabase, +} from '@test-utils/database-setup' +import { useAccountsStore } from '@perawallet/wallet-core-accounts' +import { + mockAlgodAccountInformation, + mockAlgodStatus, + mockIndexerSearchForAccounts, +} from '@perawallet/wallet-core-blockchain/test-handlers' +import { LedgerSelectAccountsScreen } from '@modules/ledger/screens/LedgerSelectAccountsScreen' + +import { HD_TEST_ADDRESS } from './__fixtures__/onboarding' + +const SLOW_TEST_TIMEOUT_MS = 30000 + +const LEDGER_ADDRESS = HD_TEST_ADDRESS + +describe('Flow: Ledger account info sheet', () => { + beforeAll(async () => { + server.listen({ onUnhandledRequest: 'warn' }) + await setupTestDatabase() + }) + afterEach(() => server.resetHandlers()) + afterAll(async () => { + server.close() + await teardownTestDatabase() + }) + + beforeEach(async () => { + await resetTestDatabase() + await seedAlgoAsset('mainnet') + resetTestKeystore() + useAccountsStore.getState().setAccounts([]) + + server.use( + mockAlgodAccountInformation({ + address: LEDGER_ADDRESS, + response: { amount: 408_200_000, 'min-balance': 100_000 }, + }), + mockAlgodStatus({ response: { 'last-round': 100 } }), + mockIndexerSearchForAccounts(), + ) + }) + + it( + 'Given a discovered Ledger account, when the user taps the ⓘ affordance, then the account info sheet opens and shows the Ledger title and account info list', + async () => { + renderWithNavigation( + LedgerSelectAccountsScreen, + 'LedgerSelectAccounts', + { + initialParams: { + deviceId: 'test-device-id', + deviceName: 'Ledger Nano X', + transportType: 'ble', + accounts: [ + { + address: LEDGER_ADDRESS, + publicKey: new Uint8Array([1]), + accountIndex: 0, + }, + ], + }, + }, + ) + + // Assert the ⓘ affordance renders for the discovered account + const infoButton = await screen.findByTestId( + `ledger_select_row_${LEDGER_ADDRESS}-info`, + ) + + // Sheet should NOT be open initially + expect( + screen.queryByTestId('ledger_account_info_list'), + ).toBeNull() + + // Tap the ⓘ button to open the info sheet + fireEvent.click(infoButton) + + // Sheet opened and the list rendered + await waitFor( + () => + expect( + screen.getByTestId('ledger_account_info_list'), + ).toBeTruthy(), + { timeout: 10000 }, + ) + + // The sheet title should be "Ledger #0" + await waitFor( + () => expect(screen.getByText('Ledger #0')).toBeTruthy(), + { timeout: 10000 }, + ) + }, + SLOW_TEST_TIMEOUT_MS, + ) +}) From 4b5eed9dd7feedb903a276bc20d7d3a965c7d7b4 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 18 May 2026 13:53:01 +0100 Subject: [PATCH 16/39] test(ledger): move ledger-account svg stub to vitest.integration-setup --- .../__integration__/ledger-account-info-sheet.test.tsx | 9 --------- apps/mobile/vitest.integration-setup.ts | 7 +++++++ 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/apps/mobile/src/__integration__/ledger-account-info-sheet.test.tsx b/apps/mobile/src/__integration__/ledger-account-info-sheet.test.tsx index 5a73b1832..c69867b6c 100644 --- a/apps/mobile/src/__integration__/ledger-account-info-sheet.test.tsx +++ b/apps/mobile/src/__integration__/ledger-account-info-sheet.test.tsx @@ -18,18 +18,9 @@ import { describe, expect, it, - vi, } from 'vitest' import { fireEvent, screen, waitFor } from '@testing-library/react' -// The ledger account SVG ships as a data-URI-encoded clip-path that JSDOM -// rejects with InvalidCharacterError when it tries to parse the attribute -// as an XML Name. Stub it out the same way vitest.integration-setup.ts -// stubs other SVGs that trigger this JSDOM limitation. -vi.mock('@assets/icons/accounts/light/ledger-account.svg', () => ({ - default: () => null, -})) - import { server } from '@test-utils/msw-server' import { renderWithNavigation } from '@test-utils/renderWithNavigation' import { resetTestKeystore } from '@test-utils/algorand-keystore-test' diff --git a/apps/mobile/vitest.integration-setup.ts b/apps/mobile/vitest.integration-setup.ts index 30e5a4481..ae4b13198 100644 --- a/apps/mobile/vitest.integration-setup.ts +++ b/apps/mobile/vitest.integration-setup.ts @@ -140,6 +140,13 @@ vi.mock('@assets/icons/check.svg', () => { React.createElement('div', { ...props, 'data-testid': 'SvgIcon' }), } }) +vi.mock('@assets/icons/accounts/light/ledger-account.svg', () => { + const React = require('react') + return { + default: (props: Record) => + React.createElement('div', { ...props, 'data-testid': 'SvgIcon' }), + } +}) // `expo-modules-core` references the React-Native `__DEV__` global at // module-load time (in `setUpJsLogger.fx.ts`). Under jsdom this is From 5e5691361ac531859a6108d13cd12b7bd5aac218 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 18 May 2026 13:56:05 +0100 Subject: [PATCH 17/39] style(ledger): apply formatter and copyright-header fixes --- .../ledger-account-info-sheet.test.tsx | 4 +- .../LedgerAccountInfoContent.tsx | 55 ++++++++++--------- .../useLedgerSelectAccountsScreen.tsx | 5 +- .../prefetchLedgerAccountPreview.test.ts | 8 ++- .../__tests__/useLedgerAccountPreview.test.ts | 11 ++-- .../useRekeyedAddressesQuery.test.ts | 7 +-- .../src/hooks/useLedgerAccountPreview.ts | 5 +- 7 files changed, 52 insertions(+), 43 deletions(-) diff --git a/apps/mobile/src/__integration__/ledger-account-info-sheet.test.tsx b/apps/mobile/src/__integration__/ledger-account-info-sheet.test.tsx index c69867b6c..2007911fe 100644 --- a/apps/mobile/src/__integration__/ledger-account-info-sheet.test.tsx +++ b/apps/mobile/src/__integration__/ledger-account-info-sheet.test.tsx @@ -99,9 +99,7 @@ describe('Flow: Ledger account info sheet', () => { ) // Sheet should NOT be open initially - expect( - screen.queryByTestId('ledger_account_info_list'), - ).toBeNull() + expect(screen.queryByTestId('ledger_account_info_list')).toBeNull() // Tap the ⓘ button to open the info sheet fireEvent.click(infoButton) diff --git a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoContent.tsx b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoContent.tsx index 2c54866f3..77fa8a149 100644 --- a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoContent.tsx +++ b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoContent.tsx @@ -51,31 +51,36 @@ export const LedgerAccountInfoContent = ({ const { title, items, isLoading, isError, refetch } = useLedgerAccountInfoContent(address, accountIndex) - const renderItem = useCallback(({ item }: { item: LedgerInfoListItem }) => { - switch (item.kind) { - case 'sectionHeader': - return - case 'account': - return ( - - ) - case 'asset': - return ( - - ) - case 'rekeyAddress': - return - } - }, [t, preferredCurrency]) + const renderItem = useCallback( + ({ item }: { item: LedgerInfoListItem }) => { + switch (item.kind) { + case 'sectionHeader': + return + case 'account': + return ( + + ) + case 'asset': + return ( + + ) + case 'rekeyAddress': + return + } + }, + [t, preferredCurrency], + ) return ( diff --git a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.tsx b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.tsx index b09eeb366..672936211 100644 --- a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.tsx +++ b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.tsx @@ -25,7 +25,10 @@ import { } from '@perawallet/wallet-core-ledger' import type { HardwareWalletTransport } from '@perawallet/wallet-core-hardware-wallet' import type { Nullable } from '@perawallet/wallet-core-shared' -import { useAlgorandClient, useNetwork } from '@perawallet/wallet-core-blockchain' +import { + useAlgorandClient, + useNetwork, +} from '@perawallet/wallet-core-blockchain' import { useAppNavigation } from '@hooks/useAppNavigation' import { useLanguage } from '@hooks/useLanguage' import { useToast } from '@hooks/useToast' diff --git a/packages/accounts/src/hooks/__tests__/prefetchLedgerAccountPreview.test.ts b/packages/accounts/src/hooks/__tests__/prefetchLedgerAccountPreview.test.ts index eded24c18..412a5f381 100644 --- a/packages/accounts/src/hooks/__tests__/prefetchLedgerAccountPreview.test.ts +++ b/packages/accounts/src/hooks/__tests__/prefetchLedgerAccountPreview.test.ts @@ -47,7 +47,9 @@ describe('prefetchLedgerAccountPreview', () => { }) it('primes the on-chain info and rekeyed-addresses query caches', async () => { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) const algokit = {} as never await prefetchLedgerAccountPreview( @@ -76,7 +78,9 @@ describe('prefetchLedgerAccountPreview', () => { it('never rejects when a fetch fails (best-effort)', async () => { mocks.fetchRekeyedAddresses.mockRejectedValue(new Error('network')) - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) await expect( prefetchLedgerAccountPreview( diff --git a/packages/accounts/src/hooks/__tests__/useLedgerAccountPreview.test.ts b/packages/accounts/src/hooks/__tests__/useLedgerAccountPreview.test.ts index ae07be28d..53ea71a63 100644 --- a/packages/accounts/src/hooks/__tests__/useLedgerAccountPreview.test.ts +++ b/packages/accounts/src/hooks/__tests__/useLedgerAccountPreview.test.ts @@ -24,8 +24,7 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('../useOnChainAccountInformationQuery', () => ({ - useOnChainAccountInformationQuery: - mocks.useOnChainAccountInformationQuery, + useOnChainAccountInformationQuery: mocks.useOnChainAccountInformationQuery, })) vi.mock('../useRekeyedAddressesQuery', () => ({ useRekeyedAddressesQuery: mocks.useRekeyedAddressesQuery, @@ -53,7 +52,9 @@ beforeEach(() => { }) mocks.useAssetsQuery.mockReturnValue({ data: new Map(), isPending: false }) mocks.useAssetPricesQuery.mockReturnValue({ - data: new Map([[ALGO_ID, { assetId: ALGO_ID, usdPrice: new Decimal(2) }]]), + data: new Map([ + [ALGO_ID, { assetId: ALGO_ID, usdPrice: new Decimal(2) }], + ]), isPending: false, }) mocks.useRekeyedAddressesQuery.mockReturnValue({ @@ -97,7 +98,9 @@ describe('useLedgerAccountPreview', () => { minBalance: 0n, status: 'Offline', rewards: 0n, - assets: [{ assetId: 31566704n, amount: 1_500_000n, isFrozen: false }], + assets: [ + { assetId: 31566704n, amount: 1_500_000n, isFrozen: false }, + ], }, isLoading: false, isError: false, diff --git a/packages/accounts/src/hooks/__tests__/useRekeyedAddressesQuery.test.ts b/packages/accounts/src/hooks/__tests__/useRekeyedAddressesQuery.test.ts index 73503648d..1768d7354 100644 --- a/packages/accounts/src/hooks/__tests__/useRekeyedAddressesQuery.test.ts +++ b/packages/accounts/src/hooks/__tests__/useRekeyedAddressesQuery.test.ts @@ -57,10 +57,9 @@ describe('useRekeyedAddressesQuery', () => { it('returns the addresses rekeyed to the given address', async () => { mocks.fetchRekeyedAddresses.mockResolvedValue(['REKEYED1', 'REKEYED2']) - const { result } = renderHook( - () => useRekeyedAddressesQuery('ADDR'), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useRekeyedAddressesQuery('ADDR'), { + wrapper: createWrapper(), + }) await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(result.current.data).toEqual(['REKEYED1', 'REKEYED2']) diff --git a/packages/accounts/src/hooks/useLedgerAccountPreview.ts b/packages/accounts/src/hooks/useLedgerAccountPreview.ts index f027c410a..940e1c58e 100644 --- a/packages/accounts/src/hooks/useLedgerAccountPreview.ts +++ b/packages/accounts/src/hooks/useLedgerAccountPreview.ts @@ -46,10 +46,7 @@ export const useLedgerAccountPreview = ( ) const { data: assets } = useAssetsQuery(assetIds) - const priceIds = useMemo( - () => [ALGO_ASSET_ID, ...assetIds], - [assetIds], - ) + const priceIds = useMemo(() => [ALGO_ASSET_ID, ...assetIds], [assetIds]) const { data: prices } = useAssetPricesQuery(priceIds) const preview = useMemo(() => { From 69f91aa5c6e3889cf44aecbad17d2b93ff57a72a Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 18 May 2026 14:11:52 +0100 Subject: [PATCH 18/39] fix(ledger): carry asset decimals for display precision; strengthen info-sheet integration assertions --- .../ledger-account-info-sheet.test.tsx | 15 +++++++++++++++ .../LedgerAccountInfoRows.tsx | 2 +- .../__tests__/useLedgerAccountPreview.test.ts | 3 +++ .../accounts/src/hooks/useLedgerAccountPreview.ts | 2 ++ .../accounts/src/models/ledger-account-preview.ts | 4 +++- 5 files changed, 24 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/__integration__/ledger-account-info-sheet.test.tsx b/apps/mobile/src/__integration__/ledger-account-info-sheet.test.tsx index 2007911fe..4a40860b1 100644 --- a/apps/mobile/src/__integration__/ledger-account-info-sheet.test.tsx +++ b/apps/mobile/src/__integration__/ledger-account-info-sheet.test.tsx @@ -118,6 +118,21 @@ describe('Flow: Ledger account info sheet', () => { () => expect(screen.getByText('Ledger #0')).toBeTruthy(), { timeout: 10000 }, ) + + // Section headers should be rendered. + // Note: i18n is not initialised in the integration test harness, + // so t() returns the raw key — assert on what is actually rendered. + await waitFor( + () => { + expect( + screen.getByText('ledger.account_info.account_details'), + ).toBeTruthy() + expect( + screen.getByText('ledger.account_info.assets'), + ).toBeTruthy() + }, + { timeout: 10000 }, + ) }, SLOW_TEST_TIMEOUT_MS, ) diff --git a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoRows.tsx b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoRows.tsx index 2917163d4..db23bf17e 100644 --- a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoRows.tsx +++ b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoRows.tsx @@ -131,7 +131,7 @@ export const LedgerAssetRow = ({ { expect(result.current.preview?.assets).toHaveLength(1) expect(result.current.preview?.assets[0].isAlgo).toBe(true) expect(result.current.preview?.assets[0].unitName).toBe('ALGO') + expect(result.current.preview?.assets[0].decimals).toBe(6) }) it('includes ASA holdings with metadata, fiat value and verification tier', () => { @@ -141,6 +142,7 @@ describe('useLedgerAccountPreview', () => { expect(usdc?.fiatValue.toString()).toBe('1.5') expect(usdc?.verificationTier).toBe('verified') expect(usdc?.name).toBe('USDC') + expect(usdc?.decimals).toBe(6) }) it('falls back to asset-id string, empty unitName, unverified tier and decimals=0 when asset metadata is missing', () => { @@ -174,6 +176,7 @@ describe('useLedgerAccountPreview', () => { expect(asa?.amount.toString()).toBe('42') expect(asa?.fiatValue.toString()).toBe('0') expect(asa?.isAlgo).toBe(false) + expect(asa?.decimals).toBe(0) }) it('reports rekeyedTo when the account is rekeyed', () => { diff --git a/packages/accounts/src/hooks/useLedgerAccountPreview.ts b/packages/accounts/src/hooks/useLedgerAccountPreview.ts index 940e1c58e..d60efbbf0 100644 --- a/packages/accounts/src/hooks/useLedgerAccountPreview.ts +++ b/packages/accounts/src/hooks/useLedgerAccountPreview.ts @@ -63,6 +63,7 @@ export const useLedgerAccountPreview = ( assetId: ALGO_ASSET_ID, name: ALGO_ASSET.name ?? 'Algo', unitName: ALGO_ASSET.unitName ?? 'ALGO', + decimals: ALGO_ASSET.decimals, amount: algoBalance, fiatValue: usdToPreferred(algoBalance.times(algoUsdPrice)), verificationTier: PeraAssetVerificationTier.verified, @@ -82,6 +83,7 @@ export const useLedgerAccountPreview = ( assetId: id, name: meta?.name ?? id, unitName: meta?.unitName ?? '', + decimals, amount, fiatValue: usdToPreferred(usdValue), verificationTier: diff --git a/packages/accounts/src/models/ledger-account-preview.ts b/packages/accounts/src/models/ledger-account-preview.ts index 766367716..4f9a63007 100644 --- a/packages/accounts/src/models/ledger-account-preview.ts +++ b/packages/accounts/src/models/ledger-account-preview.ts @@ -17,6 +17,8 @@ export type LedgerAccountPreviewAsset = { assetId: string name: string unitName: string + /** Asset decimals (display precision) */ + decimals: number /** Holding amount in display units */ amount: Decimal /** Holding value in the user's preferred currency */ @@ -37,7 +39,7 @@ export type LedgerAccountPreview = { algoBalance: Decimal /** Total account value in the user's preferred currency */ totalFiatValue: Decimal - /** ALGO first, then assets ordered as `useSortedAssetBalances` orders them */ + /** ALGO first, then the account's on-chain holdings in algod order */ assets: LedgerAccountPreviewAsset[] rekey: LedgerAccountRekeyRelationship } From 07e44fd702493f125c234be7ff4da7b5c1407931 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 18 May 2026 14:40:16 +0100 Subject: [PATCH 19/39] feat(accounts): add LedgerSelectableAccount model + useLedgerRekeyedScan --- .../__tests__/useLedgerRekeyedScan.test.ts | 91 +++++++++++++++++++ packages/accounts/src/hooks/index.ts | 1 + .../src/hooks/useLedgerRekeyedScan.ts | 75 +++++++++++++++ packages/accounts/src/models/index.ts | 1 + .../src/models/ledger-selectable-account.ts | 27 ++++++ 5 files changed, 195 insertions(+) create mode 100644 packages/accounts/src/hooks/__tests__/useLedgerRekeyedScan.test.ts create mode 100644 packages/accounts/src/hooks/useLedgerRekeyedScan.ts create mode 100644 packages/accounts/src/models/ledger-selectable-account.ts diff --git a/packages/accounts/src/hooks/__tests__/useLedgerRekeyedScan.test.ts b/packages/accounts/src/hooks/__tests__/useLedgerRekeyedScan.test.ts new file mode 100644 index 000000000..2ffe07be5 --- /dev/null +++ b/packages/accounts/src/hooks/__tests__/useLedgerRekeyedScan.test.ts @@ -0,0 +1,91 @@ +/* + 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 } from '@testing-library/react' +import { useLedgerRekeyedScan } from '../useLedgerRekeyedScan' + +const mocks = vi.hoisted(() => ({ + useQueries: vi.fn(), + useNetwork: vi.fn(), + useAllAccounts: vi.fn(), +})) + +vi.mock('@tanstack/react-query', () => ({ useQueries: mocks.useQueries })) +vi.mock('@perawallet/wallet-core-blockchain', () => ({ + useNetwork: mocks.useNetwork, +})) +vi.mock('../useAllAccounts', () => ({ useAllAccounts: mocks.useAllAccounts })) + +const derived = (address: string, accountIndex: number) => ({ + address, + publicKey: new Uint8Array([accountIndex]), + accountIndex, +}) + +beforeEach(() => { + vi.clearAllMocks() + mocks.useNetwork.mockReturnValue({ network: 'mainnet' }) + mocks.useAllAccounts.mockReturnValue([]) +}) + +describe('useLedgerRekeyedScan', () => { + it('maps rekeyed addresses to entries attributed to the scanned derived account', () => { + const d0 = derived('LEDGER0', 0) + mocks.useQueries.mockReturnValue([ + { data: ['REKEYED_A', 'REKEYED_B'], isPending: false }, + ]) + + const { result } = renderHook(() => useLedgerRekeyedScan([d0])) + + expect(result.current.isScanning).toBe(false) + expect(result.current.rekeyed).toEqual([ + { kind: 'rekeyed', address: 'REKEYED_A', authAccount: d0 }, + { kind: 'rekeyed', address: 'REKEYED_B', authAccount: d0 }, + ]) + }) + + it('dedupes vs derived addresses, already-imported addresses, and repeats', () => { + const d0 = derived('LEDGER0', 0) + const d1 = derived('LEDGER1', 1) + mocks.useAllAccounts.mockReturnValue([{ address: 'IMPORTED' }]) + mocks.useQueries.mockReturnValue([ + { data: ['REKEYED_A', 'LEDGER1', 'IMPORTED'], isPending: false }, + { data: ['REKEYED_A', 'REKEYED_C'], isPending: false }, + ]) + + const { result } = renderHook(() => useLedgerRekeyedScan([d0, d1])) + + expect(result.current.rekeyed).toEqual([ + { kind: 'rekeyed', address: 'REKEYED_A', authAccount: d0 }, + { kind: 'rekeyed', address: 'REKEYED_C', authAccount: d1 }, + ]) + }) + + it('reports isScanning while any query is pending and tolerates missing data', () => { + const d0 = derived('LEDGER0', 0) + mocks.useQueries.mockReturnValue([ + { data: undefined, isPending: true }, + ]) + + const { result } = renderHook(() => useLedgerRekeyedScan([d0])) + + expect(result.current.isScanning).toBe(true) + expect(result.current.rekeyed).toEqual([]) + }) + + it('returns empty and not scanning for no derived accounts', () => { + mocks.useQueries.mockReturnValue([]) + const { result } = renderHook(() => useLedgerRekeyedScan([])) + expect(result.current).toEqual({ rekeyed: [], isScanning: false }) + }) +}) diff --git a/packages/accounts/src/hooks/index.ts b/packages/accounts/src/hooks/index.ts index b9c77b142..b2114e00f 100644 --- a/packages/accounts/src/hooks/index.ts +++ b/packages/accounts/src/hooks/index.ts @@ -43,6 +43,7 @@ export * from './useRekeyTransition' export * from './useRekeyedAddressesQuery' export * from './useLedgerAccountPreview' export * from './prefetchLedgerAccountPreview' +export * from './useLedgerRekeyedScan' export * from './useOwnedAssets' export * from './useHDImportSession' export { invalidateAccountQueries, isAccountQuery } from './querykeys' diff --git a/packages/accounts/src/hooks/useLedgerRekeyedScan.ts b/packages/accounts/src/hooks/useLedgerRekeyedScan.ts new file mode 100644 index 000000000..5c7342879 --- /dev/null +++ b/packages/accounts/src/hooks/useLedgerRekeyedScan.ts @@ -0,0 +1,75 @@ +/* + 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 { useQueries } from '@tanstack/react-query' +import { useNetwork } from '@perawallet/wallet-core-blockchain' +import type { HardwareWalletDerivedAccount } from '@perawallet/wallet-core-hardware-wallet' +import { fetchRekeyedAddresses } from '../account-discovery' +import { getRekeyedAddressesQueryKey } from './querykeys' +import { useAllAccounts } from './useAllAccounts' +import type { LedgerSelectableAccount } from '../models' + +type UseLedgerRekeyedScanResult = { + rekeyed: LedgerSelectableAccount[] + isScanning: boolean +} + +/** + * For each discovered Ledger (derived) account, scans the indexer for accounts + * rekeyed to it (reusing the cached `rekeyed-addresses` query warmed by + * `prefetchLedgerAccountPreview`) and returns them as `rekeyed` selectables. + * Best-effort: a failed/empty scan yields no rows for that address. + */ +export const useLedgerRekeyedScan = ( + derivedAccounts: HardwareWalletDerivedAccount[], +): UseLedgerRekeyedScanResult => { + const { network } = useNetwork() + const allAccounts = useAllAccounts() + + const results = useQueries({ + queries: derivedAccounts.map(acc => ({ + queryKey: getRekeyedAddressesQueryKey(acc.address, network), + queryFn: () => fetchRekeyedAddresses(acc.address), + staleTime: 0, + })), + }) + + return useMemo(() => { + const derivedAddresses = new Set( + derivedAccounts.map(a => a.address), + ) + const importedAddresses = new Set(allAccounts.map(a => a.address)) + const seen = new Set() + const rekeyed: LedgerSelectableAccount[] = [] + + results.forEach((res, idx) => { + const authAccount = derivedAccounts[idx] + if (!authAccount) return + const addresses: string[] = res.data ?? [] + for (const address of addresses) { + if ( + derivedAddresses.has(address) || + importedAddresses.has(address) || + seen.has(address) + ) { + continue + } + seen.add(address) + rekeyed.push({ kind: 'rekeyed', address, authAccount }) + } + }) + + const isScanning = results.some(r => r.isPending) + return { rekeyed, isScanning } + }, [results, derivedAccounts, allAccounts]) +} diff --git a/packages/accounts/src/models/index.ts b/packages/accounts/src/models/index.ts index 271b64760..c54656040 100644 --- a/packages/accounts/src/models/index.ts +++ b/packages/accounts/src/models/index.ts @@ -16,6 +16,7 @@ import type { BaseStoreState, Nullable } from '@perawallet/wallet-core-shared' export * from './accounts' export * from './balances' export * from './ledger-account-preview' +export * from './ledger-selectable-account' export type AccountsState = BaseStoreState & { accounts: WalletAccount[] diff --git a/packages/accounts/src/models/ledger-selectable-account.ts b/packages/accounts/src/models/ledger-selectable-account.ts new file mode 100644 index 000000000..64b9d244b --- /dev/null +++ b/packages/accounts/src/models/ledger-selectable-account.ts @@ -0,0 +1,27 @@ +/* + 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 type { HardwareWalletDerivedAccount } from '@perawallet/wallet-core-hardware-wallet' + +/** + * An account selectable in the Ledger import "N accounts found" list. + * `derived` = an address derived on the Ledger device. + * `rekeyed` = an account rekeyed TO `authAccount` (a derived Ledger account); + * it is never device-verified — its `authAccount` is. + */ +export type LedgerSelectableAccount = + | { kind: 'derived'; account: HardwareWalletDerivedAccount } + | { + kind: 'rekeyed' + address: string + authAccount: HardwareWalletDerivedAccount + } From 7b8e138cd78812f3285a96034dd16a30cb1e0fc4 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 18 May 2026 14:46:04 +0100 Subject: [PATCH 20/39] feat(ledger): optional title override for the account info sheet --- .../LedgerAccountInfoContent.tsx | 8 +++++--- .../__tests__/useLedgerAccountInfoContent.spec.ts | 15 +++++++++++++++ .../useLedgerAccountInfoContent.ts | 3 ++- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoContent.tsx b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoContent.tsx index 77fa8a149..c64663676 100644 --- a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoContent.tsx +++ b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoContent.tsx @@ -38,18 +38,20 @@ import { useStyles } from './styles' export type LedgerAccountInfoContentProps = { address: string accountIndex: number + title?: string } export const LedgerAccountInfoContent = ({ address, accountIndex, + title, }: LedgerAccountInfoContentProps) => { const styles = useStyles() const { t } = useLanguage() const { dismiss } = useBottomSheetResult() const { preferredCurrency } = useCurrency() - const { title, items, isLoading, isError, refetch } = - useLedgerAccountInfoContent(address, accountIndex) + const { title: resolvedTitle, items, isLoading, isError, refetch } = + useLedgerAccountInfoContent(address, accountIndex, title) const renderItem = useCallback( ({ item }: { item: LedgerInfoListItem }) => { @@ -92,7 +94,7 @@ export const LedgerAccountInfoContent = ({ testID='ledger_account_info_close' /> } - center={{title}} + center={{resolvedTitle}} /> {isLoading && ( diff --git a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/__tests__/useLedgerAccountInfoContent.spec.ts b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/__tests__/useLedgerAccountInfoContent.spec.ts index 48ddd3d75..0b08fac47 100644 --- a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/__tests__/useLedgerAccountInfoContent.spec.ts +++ b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/__tests__/useLedgerAccountInfoContent.spec.ts @@ -136,6 +136,21 @@ describe('useLedgerAccountInfoContent', () => { expect(rekeyAddresses).toEqual(['R1', 'R2']) }) + it('uses the title override when provided, ignoring the index', () => { + mocks.useLedgerAccountPreview.mockReturnValue({ + preview: undefined, + isLoading: true, + isError: false, + refetch: vi.fn(), + }) + + const { result } = renderHook(() => + useLedgerAccountInfoContent('ADDR', 7, 'Rekeyed account'), + ) + + expect(result.current.title).toBe('Rekeyed account') + }) + it('carries Decimal instances for algoBalance and fiatValue on the account item', () => { mocks.useLedgerAccountPreview.mockReturnValue({ preview: { diff --git a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/useLedgerAccountInfoContent.ts b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/useLedgerAccountInfoContent.ts index 9c728ca7c..2d66d911e 100644 --- a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/useLedgerAccountInfoContent.ts +++ b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/useLedgerAccountInfoContent.ts @@ -41,6 +41,7 @@ type UseLedgerAccountInfoContentResult = { export const useLedgerAccountInfoContent = ( address: string, accountIndex: number, + titleOverride?: string, ): UseLedgerAccountInfoContentResult => { const { t } = useLanguage() const { preview, isLoading, isError, refetch } = @@ -108,7 +109,7 @@ export const useLedgerAccountInfoContent = ( }, [preview, t]) return { - title: `Ledger #${accountIndex}`, + title: titleOverride ?? `Ledger #${accountIndex}`, items, isLoading, isError, From 9a8cafcd486684ef70632c99e1dfa6b0708c5624 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 18 May 2026 14:49:04 +0100 Subject: [PATCH 21/39] docs(ledger): document titleOverride param --- .../LedgerAccountInfoContent/useLedgerAccountInfoContent.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/useLedgerAccountInfoContent.ts b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/useLedgerAccountInfoContent.ts index 2d66d911e..a6f818133 100644 --- a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/useLedgerAccountInfoContent.ts +++ b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/useLedgerAccountInfoContent.ts @@ -41,6 +41,7 @@ type UseLedgerAccountInfoContentResult = { export const useLedgerAccountInfoContent = ( address: string, accountIndex: number, + /** When provided, used as the sheet title instead of the default `Ledger #N` label. */ titleOverride?: string, ): UseLedgerAccountInfoContentResult => { const { t } = useLanguage() From 67d860d75b96915f6791380082cb371dc03c0972 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 18 May 2026 14:57:17 +0100 Subject: [PATCH 22/39] =?UTF-8?q?feat(ledger):=20thread=20LedgerSelectable?= =?UTF-8?q?Account=20union=20select=E2=86=92verify=E2=86=92import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../useLedgerSelectAccountsScreen.spec.ts | 29 ++++ .../useLedgerSelectAccountsScreen.tsx | 9 +- .../LedgerVerifyScreen/LedgerVerifyScreen.tsx | 3 +- .../__tests__/useLedgerVerifyScreen.spec.ts | 164 ++++++++++++++++++ .../useLedgerVerifyScreen.ts | 120 ++++++++++--- .../src/modules/onboarding/routes/types.ts | 3 +- 6 files changed, 297 insertions(+), 31 deletions(-) create mode 100644 apps/mobile/src/modules/ledger/screens/LedgerVerifyScreen/__tests__/useLedgerVerifyScreen.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 index 61df5513e..a76481d31 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 @@ -396,4 +396,33 @@ describe('useLedgerSelectAccountsScreen', () => { ) }) }) + + it('navigates to LedgerVerify with selectedAccounts wrapped as derived LedgerSelectableAccount', () => { + const { result } = renderHook(() => useLedgerSelectAccountsScreen()) + + act(() => { + result.current.toggleSelection('AAA111') + }) + + act(() => { + result.current.handleContinue() + }) + + expect(mockNavigate).toHaveBeenCalledTimes(1) + expect(mockNavigate).toHaveBeenCalledWith('LedgerVerify', { + deviceId: 'device-1', + deviceName: 'Fred Nano X', + transportType: 'ble', + selectedAccounts: [ + { + kind: 'derived', + account: { + address: 'AAA111', + publicKey: new Uint8Array([1]), + accountIndex: 0, + }, + }, + ], + }) + }) }) diff --git a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.tsx b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.tsx index 672936211..a592b2f7e 100644 --- a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.tsx +++ b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.tsx @@ -17,6 +17,7 @@ import { getProvider } from '@perawallet/wallet-extension-provider' import { useAllAccounts, prefetchLedgerAccountPreview, + type LedgerSelectableAccount, } from '@perawallet/wallet-core-accounts' import type { LedgerAccount } from '@perawallet/wallet-core-ledger' import { @@ -160,11 +161,15 @@ export const useLedgerSelectAccountsScreen = }, [isAllSelected, newAccounts]) const handleContinue = useCallback(() => { - const selectedAccounts = accounts.filter(acc => + const selected = accounts.filter(acc => selectedAddresses.has(acc.address), ) - if (selectedAccounts.length === 0) return + if (selected.length === 0) return + + const selectedAccounts: LedgerSelectableAccount[] = selected.map( + account => ({ kind: 'derived', account }), + ) navigation.navigate('LedgerVerify', { deviceId, diff --git a/apps/mobile/src/modules/ledger/screens/LedgerVerifyScreen/LedgerVerifyScreen.tsx b/apps/mobile/src/modules/ledger/screens/LedgerVerifyScreen/LedgerVerifyScreen.tsx index 10949c553..8bcfa1511 100644 --- a/apps/mobile/src/modules/ledger/screens/LedgerVerifyScreen/LedgerVerifyScreen.tsx +++ b/apps/mobile/src/modules/ledger/screens/LedgerVerifyScreen/LedgerVerifyScreen.tsx @@ -28,6 +28,7 @@ export const LedgerVerifyScreen = () => { const styles = useStyles() const { selectedAccounts, + verifyTargets, verifiedIndices, areAllVerified, errorPreset, @@ -80,7 +81,7 @@ export const LedgerVerifyScreen = () => { - {selectedAccounts.map((acc, i) => ( + {verifyTargets.map((acc, i) => ( { + const actual = + await importOriginal() + return { ...actual } +}) + +import { useLedgerVerifyScreen } from '../useLedgerVerifyScreen' + +const { + mockVerify, + mockExit, + mockSetConfetti, + mockConnect, + mockDisconnect, +} = vi.hoisted(() => ({ + mockVerify: vi.fn(), + mockExit: vi.fn(), + mockSetConfetti: vi.fn(), + mockConnect: vi.fn(), + mockDisconnect: vi.fn(), +})) + +vi.mock('@hooks/useAppNavigation', () => ({ + useAppNavigation: () => ({ navigate: vi.fn() }), +})) +vi.mock('@hooks/useLanguage', () => ({ + useLanguage: () => ({ t: (k: string) => k }), +})) +vi.mock('@modules/onboarding/hooks', () => ({ + useExitAccountFlow: () => ({ exitAccountFlow: mockExit }), + useShouldPlayConfetti: () => ({ setShouldPlayConfetti: mockSetConfetti }), +})) +vi.mock('@modules/ledger/utils', () => ({ + getLedgerErrorPreset: () => ({ title: 't', body: 'b' }), +})) +vi.mock('@perawallet/wallet-extension-provider', () => ({ + getProvider: () => ({ + keyValueStorage: { + getItem: vi.fn().mockResolvedValue(null), + setItem: vi.fn().mockResolvedValue(undefined), + removeItem: vi.fn().mockResolvedValue(undefined), + }, + hardwareWalletRegistry: { + getProvider: () => ({ connect: mockConnect }), + }, + }), +})) +vi.mock('@perawallet/wallet-core-ledger', () => ({ + verifyLedgerAddress: mockVerify, + LedgerProviderNotFoundError: class extends Error {}, + classifyLedgerError: (e: unknown) => e as Error, +})) + +const routeParams = vi.hoisted(() => ({ current: {} as Record })) +vi.mock('@react-navigation/native', () => ({ + useRoute: () => ({ params: routeParams.current }), +})) +vi.mock('@perawallet/wallet-core-blockchain', () => ({ + isValidAlgorandAddress: (address?: string) => + typeof address === 'string' && !address.startsWith('!!'), +})) + +const derived = (address: string, accountIndex: number) => ({ + address, + publicKey: new Uint8Array([accountIndex]), + accountIndex, +}) + +beforeEach(() => { + vi.clearAllMocks() + mockConnect.mockResolvedValue({ + getAddress: vi.fn(), + signTransaction: vi.fn(), + disconnect: mockDisconnect.mockResolvedValue(undefined), + }) + mockVerify.mockResolvedValue(undefined) + useAccountsStore.getState().setAccounts([]) +}) + +describe('useLedgerVerifyScreen', () => { + it('verifies one target per unique auth account (derived self, rekeyed auth, deduped)', async () => { + const d0 = derived('LEDGER0', 0) + routeParams.current = { + deviceId: 'dev', + deviceName: 'Nano', + transportType: 'ble', + selectedAccounts: [ + { kind: 'derived', account: d0 }, + { kind: 'rekeyed', address: 'REKEYED_A', authAccount: d0 }, + { kind: 'rekeyed', address: 'REKEYED_B', authAccount: d0 }, + ], + } + + const { result } = renderHook(() => useLedgerVerifyScreen()) + + await waitFor(() => + expect(result.current.areAllVerified).toBe(true), + ) + expect(mockVerify).toHaveBeenCalledTimes(1) + expect(mockVerify).toHaveBeenCalledWith(expect.anything(), 0) + expect(result.current.verifyTargets).toEqual([d0]) + }) + + it('handleAdd imports derived as hardware, rekeyed as watch+rekeyAddress, auto-includes auth, skips already-imported and invalid', async () => { + const d0 = derived('LEDGER0', 0) + useAccountsStore + .getState() + .setAccounts([ + { type: AccountTypes.watch, address: 'ALREADY' } as WalletAccount, + ]) + routeParams.current = { + deviceId: 'dev', + deviceName: 'Nano', + transportType: 'ble', + selectedAccounts: [ + { kind: 'rekeyed', address: 'REKEYED_A', authAccount: d0 }, + { kind: 'rekeyed', address: 'ALREADY', authAccount: d0 }, + { kind: 'rekeyed', address: '!!bad', authAccount: d0 }, + ], + } + + const { result } = renderHook(() => useLedgerVerifyScreen()) + await waitFor(() => + expect(result.current.areAllVerified).toBe(true), + ) + + act(() => { + result.current.handleAdd() + }) + + const accounts = useAccountsStore.getState().accounts + const hw = accounts.find(a => a.address === 'LEDGER0') + const watch = accounts.find(a => a.address === 'REKEYED_A') + expect(hw?.type).toBe(AccountTypes.hardware) + expect(watch?.type).toBe(AccountTypes.watch) + expect(watch?.rekeyAddress).toBe('LEDGER0') + expect( + accounts.filter(a => a.address === 'ALREADY'), + ).toHaveLength(1) + expect(accounts.find(a => a.address === '!!bad')).toBeUndefined() + expect(mockExit).toHaveBeenCalledTimes(1) + expect(mockSetConfetti).toHaveBeenCalledWith(true) + }) +}) diff --git a/apps/mobile/src/modules/ledger/screens/LedgerVerifyScreen/useLedgerVerifyScreen.ts b/apps/mobile/src/modules/ledger/screens/LedgerVerifyScreen/useLedgerVerifyScreen.ts index b9e09d4a4..0956f3693 100644 --- a/apps/mobile/src/modules/ledger/screens/LedgerVerifyScreen/useLedgerVerifyScreen.ts +++ b/apps/mobile/src/modules/ledger/screens/LedgerVerifyScreen/useLedgerVerifyScreen.ts @@ -18,15 +18,24 @@ import { useSetAccounts, useSelectedAccountAddress, AccountTypes, + type LedgerSelectableAccount, + type WalletAccount, } from '@perawallet/wallet-core-accounts' -import type { HardwareWalletTransport } from '@perawallet/wallet-core-hardware-wallet' -import type { LedgerAccount } from '@perawallet/wallet-core-ledger' +import type { + HardwareWalletDerivedAccount, + HardwareWalletTransport, +} from '@perawallet/wallet-core-hardware-wallet' import { verifyLedgerAddress, LedgerProviderNotFoundError, classifyLedgerError, } from '@perawallet/wallet-core-ledger' -import type { AppError, Nullable } from '@perawallet/wallet-core-shared' +import { isValidAlgorandAddress } from '@perawallet/wallet-core-blockchain' +import { + generateOrderedUniqueId, + type AppError, + type Nullable, +} from '@perawallet/wallet-core-shared' import { useAppNavigation } from '@hooks/useAppNavigation' import { useLanguage } from '@hooks/useLanguage' import type { AddAccountStackParamList } from '@modules/onboarding/routes/types' @@ -42,7 +51,8 @@ import { type LedgerVerifyRouteProp = RouteProp type UseLedgerVerifyScreenResult = { - selectedAccounts: ReadonlyArray + selectedAccounts: ReadonlyArray + verifyTargets: ReadonlyArray verifiedIndices: ReadonlySet areAllVerified: boolean errorPreset: Nullable @@ -54,6 +64,11 @@ type UseLedgerVerifyScreenResult = { type VerificationState = 'connecting' | 'verifying' | 'complete' | 'error' +const authAccountOf = ( + s: LedgerSelectableAccount, +): HardwareWalletDerivedAccount => + s.kind === 'derived' ? s.account : s.authAccount + export const useLedgerVerifyScreen = (): UseLedgerVerifyScreenResult => { const { params: { @@ -70,6 +85,17 @@ export const useLedgerVerifyScreen = (): UseLedgerVerifyScreenResult => { const { setShouldPlayConfetti } = useShouldPlayConfetti() const navigation = useAppNavigation() + const verifyTargets = useMemo(() => { + const byIndex = new Map() + for (const sel of selectedAccounts) { + const auth = authAccountOf(sel) + if (!byIndex.has(auth.accountIndex)) { + byIndex.set(auth.accountIndex, auth) + } + } + return [...byIndex.values()] + }, [selectedAccounts]) + const [verificationState, setVerificationState] = useState('connecting') const [verifiedIndices, setVerifiedIndices] = useState>( @@ -98,10 +124,10 @@ export const useLedgerVerifyScreen = (): UseLedgerVerifyScreenResult => { transport = await provider.connect(deviceId) setVerificationState('verifying') - for (let i = 0; i < selectedAccounts.length; i++) { + for (let i = 0; i < verifyTargets.length; i++) { await verifyLedgerAddress( transport, - selectedAccounts[i].accountIndex, + verifyTargets[i].accountIndex, ) setVerifiedIndices(prev => { const next = new Set(prev) @@ -120,12 +146,9 @@ export const useLedgerVerifyScreen = (): UseLedgerVerifyScreenResult => { await transport.disconnect().catch(() => {}) } } - }, [deviceId, transportType, selectedAccounts]) + }, [deviceId, transportType, verifyTargets]) - // Run verify once on mount. The ref guards against React StrictMode's - // dev-only double-invoke (a second call would race the first against a - // different transport instance). Route params are stable for the screen's - // lifetime, so an empty dep array is intentional. + // Run verify once on mount. Ref guards StrictMode's dev double-invoke. // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { if (hasStartedRef.current) return @@ -134,21 +157,63 @@ export const useLedgerVerifyScreen = (): UseLedgerVerifyScreenResult => { }, []) const handleAdd = useCallback(() => { - const hwAccounts = selectedAccounts.map((acc: LedgerAccount) => ({ - type: AccountTypes.hardware, - address: acc.address, - hardwareDetails: { - manufacturer: 'ledger' as const, - deviceId, - deviceName, - accountIndex: acc.accountIndex, - transportType, - }, - })) + const current = useAccountsStore.getState().accounts + const existing = new Set(current.map(a => a.address)) + const added = new Set() + const batch: WalletAccount[] = [] + + const addHardware = (acc: HardwareWalletDerivedAccount) => { + if (!isValidAlgorandAddress(acc.address)) return + if (existing.has(acc.address) || added.has(acc.address)) return + added.add(acc.address) + batch.push({ + type: AccountTypes.hardware, + address: acc.address, + hardwareDetails: { + manufacturer: 'ledger' as const, + deviceId, + deviceName, + accountIndex: acc.accountIndex, + transportType, + }, + }) + } + + for (const sel of selectedAccounts) { + if (sel.kind === 'derived') { + addHardware(sel.account) + } else { + addHardware(sel.authAccount) + if ( + isValidAlgorandAddress(sel.address) && + !existing.has(sel.address) && + !added.has(sel.address) + ) { + added.add(sel.address) + batch.push({ + id: generateOrderedUniqueId(), + type: AccountTypes.watch, + address: sel.address, + rekeyAddress: sel.authAccount.address, + }) + } + } + } + + if (batch.length === 0) { + exitAccountFlow() + return + } + + setAccounts([...current, ...batch]) - const currentAccounts = useAccountsStore.getState().accounts - setAccounts([...currentAccounts, ...hwAccounts]) - setSelectedAccountAddress(hwAccounts[0].address) + const firstDerived = selectedAccounts.find( + s => s.kind === 'derived', + ) + const selectedAddress = firstDerived + ? firstDerived.account.address + : batch[0].address + setSelectedAccountAddress(selectedAddress) setShouldPlayConfetti(true) exitAccountFlow() }, [ @@ -179,11 +244,12 @@ export const useLedgerVerifyScreen = (): UseLedgerVerifyScreenResult => { ) const areAllVerified = - selectedAccounts.length > 0 && - verifiedIndices.size === selectedAccounts.length + verifyTargets.length > 0 && + verifiedIndices.size === verifyTargets.length return { selectedAccounts, + verifyTargets, verifiedIndices, areAllVerified, errorPreset, diff --git a/apps/mobile/src/modules/onboarding/routes/types.ts b/apps/mobile/src/modules/onboarding/routes/types.ts index dbdb4860c..afaa13ead 100644 --- a/apps/mobile/src/modules/onboarding/routes/types.ts +++ b/apps/mobile/src/modules/onboarding/routes/types.ts @@ -16,6 +16,7 @@ import { ImportAccountType, DerivationType, } from '@perawallet/wallet-core-accounts' +import type { LedgerSelectableAccount } from '@perawallet/wallet-core-accounts' import type { LedgerTransportType } from '@perawallet/wallet-core-hardware-wallet' import type { LedgerAccount } from '@perawallet/wallet-core-ledger' import type { Optional } from '@perawallet/wallet-core-shared' @@ -97,7 +98,7 @@ export type ImportFlowParamList = { deviceId: string deviceName: string transportType: LedgerTransportType - selectedAccounts: LedgerAccount[] + selectedAccounts: LedgerSelectableAccount[] } LedgerTroubleshooting: undefined AsbImportInfo: undefined From 99f4534b97713aa5eed71b59b28de0d8b6ae7276 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 18 May 2026 15:07:01 +0100 Subject: [PATCH 23/39] fix(ledger): never persist a rekeyed watch account without its auth Ledger account --- .../__tests__/useLedgerVerifyScreen.spec.ts | 27 +++++++++++++++++++ .../useLedgerVerifyScreen.ts | 9 ++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/modules/ledger/screens/LedgerVerifyScreen/__tests__/useLedgerVerifyScreen.spec.ts b/apps/mobile/src/modules/ledger/screens/LedgerVerifyScreen/__tests__/useLedgerVerifyScreen.spec.ts index c0a098c5f..d9db9d526 100644 --- a/apps/mobile/src/modules/ledger/screens/LedgerVerifyScreen/__tests__/useLedgerVerifyScreen.spec.ts +++ b/apps/mobile/src/modules/ledger/screens/LedgerVerifyScreen/__tests__/useLedgerVerifyScreen.spec.ts @@ -161,4 +161,31 @@ describe('useLedgerVerifyScreen', () => { expect(mockExit).toHaveBeenCalledTimes(1) expect(mockSetConfetti).toHaveBeenCalledWith(true) }) + + it('does not persist a rekeyed watch account when its auth account address is invalid', async () => { + const badAuth = { + address: '!!invalidauth', + publicKey: new Uint8Array([9]), + accountIndex: 9, + } + routeParams.current = { + deviceId: 'dev', + deviceName: 'Nano', + transportType: 'ble', + selectedAccounts: [ + { kind: 'rekeyed', address: 'REKEYED_X', authAccount: badAuth }, + ], + } + + const { result } = renderHook(() => useLedgerVerifyScreen()) + await waitFor(() => expect(result.current.areAllVerified).toBe(true)) + + act(() => { + result.current.handleAdd() + }) + + const accounts = useAccountsStore.getState().accounts + expect(accounts.find(a => a.address === 'REKEYED_X')).toBeUndefined() + expect(accounts.find(a => a.address === '!!invalidauth')).toBeUndefined() + }) }) diff --git a/apps/mobile/src/modules/ledger/screens/LedgerVerifyScreen/useLedgerVerifyScreen.ts b/apps/mobile/src/modules/ledger/screens/LedgerVerifyScreen/useLedgerVerifyScreen.ts index 0956f3693..d388bfad3 100644 --- a/apps/mobile/src/modules/ledger/screens/LedgerVerifyScreen/useLedgerVerifyScreen.ts +++ b/apps/mobile/src/modules/ledger/screens/LedgerVerifyScreen/useLedgerVerifyScreen.ts @@ -148,7 +148,10 @@ export const useLedgerVerifyScreen = (): UseLedgerVerifyScreenResult => { } }, [deviceId, transportType, verifyTargets]) - // Run verify once on mount. Ref guards StrictMode's dev double-invoke. + // Run verify once on mount. The ref guards against React StrictMode's + // dev-only double-invoke (a second call would race the first against a + // different transport instance). Route params are stable for the screen's + // lifetime, so an empty dep array is intentional. // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { if (hasStartedRef.current) return @@ -184,7 +187,11 @@ export const useLedgerVerifyScreen = (): UseLedgerVerifyScreenResult => { addHardware(sel.account) } else { addHardware(sel.authAccount) + const authPresent = + added.has(sel.authAccount.address) || + existing.has(sel.authAccount.address) if ( + authPresent && isValidAlgorandAddress(sel.address) && !existing.has(sel.address) && !added.has(sel.address) From 6a40db83413c1e00873bf6f73f646c20f5d4747e Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 18 May 2026 15:13:28 +0100 Subject: [PATCH 24/39] feat(ledger): surface rekeyed accounts in Ledger import with auth auto-include --- .../LedgerAccountSelectionRow.tsx | 9 ++ .../LedgerSelectAccountsScreen.tsx | 61 +++++++++----- .../useLedgerSelectAccountsScreen.spec.ts | 68 +++++++++++++-- .../useLedgerSelectAccountsScreen.tsx | 82 ++++++++++++++++--- 4 files changed, 184 insertions(+), 36 deletions(-) diff --git a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/LedgerAccountSelectionRow/LedgerAccountSelectionRow.tsx b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/LedgerAccountSelectionRow/LedgerAccountSelectionRow.tsx index 06ac978dd..00e0fac3a 100644 --- a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/LedgerAccountSelectionRow/LedgerAccountSelectionRow.tsx +++ b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/LedgerAccountSelectionRow/LedgerAccountSelectionRow.tsx @@ -29,6 +29,7 @@ import { useStyles } from './styles' export type LedgerAccountSelectionRowProps = { address: string accountIndex: number + variant?: 'derived' | 'rekeyed' isSelected: boolean isImported: boolean onToggle: () => void @@ -39,6 +40,7 @@ export type LedgerAccountSelectionRowProps = { export const LedgerAccountSelectionRow = ({ address, accountIndex, + variant = 'derived', isSelected, isImported, onToggle, @@ -98,6 +100,13 @@ export const LedgerAccountSelectionRow = ({ variant='secondary' /> + {variant === 'rekeyed' && ( + + )} {isImported && ( { const styles = useStyles() const { t } = useLanguage() const { - accounts, + selectableAccounts, + isScanning, selectedAddresses, isAllSelected, areAllImported, @@ -46,31 +47,49 @@ export const LedgerSelectAccountsScreen = () => { handleInfoPress, } = useLedgerSelectAccountsScreen() - const showSelectAll = accounts.length > 1 && !areAllImported + const showSelectAll = selectableAccounts.length > 1 && !areAllImported - const renderItem = ({ item }: { item: LedgerAccount }) => { - const isImported = alreadyImportedAddresses.has(item.address) - const isSelected = selectedAddresses.has(item.address) + const renderItem = ({ item }: { item: LedgerSelectableAccount }) => { + const address = + item.kind === 'derived' ? item.account.address : item.address + const accountIndex = + item.kind === 'derived' + ? item.account.accountIndex + : item.authAccount.accountIndex + const isImported = alreadyImportedAddresses.has(address) + const isSelected = selectedAddresses.has(address) return ( toggleSelection(item.address)} + onToggle={() => toggleSelection(address)} onInfoPress={handleInfoPress} - testID={`ledger_select_row_${item.address}`} + testID={`ledger_select_row_${address}`} /> ) } const renderFooter = () => ( - + <> + {isScanning && ( + + {t('ledger.select_accounts.scanning_rekeyed')} + + )} + + ) return ( @@ -88,7 +107,7 @@ export const LedgerSelectAccountsScreen = () => { style={styles.title} > {t('ledger.select_accounts.title', { - count: accounts.length, + count: selectableAccounts.length, })} { )} item.address} + keyExtractor={item => + item.kind === 'derived' + ? item.account.address + : item.address + } extraData={selectedAddresses} contentContainerStyle={styles.listContent} showsVerticalScrollIndicator={false} 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 a76481d31..a4780a5f7 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 @@ -78,12 +78,14 @@ vi.mock('@react-navigation/native', () => ({ }), })) -const { mockPrefetch, mockRequest, mockQueryClient } = vi.hoisted(() => { - const mockPrefetch = vi.fn().mockResolvedValue(undefined) - const mockRequest = vi.fn().mockResolvedValue(undefined) - const mockQueryClient = {} - return { mockPrefetch, mockRequest, mockQueryClient } -}) +const { mockPrefetch, mockRequest, mockQueryClient, mockRekeyedScan } = + vi.hoisted(() => { + const mockPrefetch = vi.fn().mockResolvedValue(undefined) + const mockRequest = vi.fn().mockResolvedValue(undefined) + const mockQueryClient = {} + const mockRekeyedScan = vi.fn() + return { mockPrefetch, mockRequest, mockQueryClient, mockRekeyedScan } + }) // useLedgerAccountPreview is included because the screen hook imports // LedgerAccountInfoContent (whose hook chain references it) at module load; @@ -92,6 +94,7 @@ vi.mock('@perawallet/wallet-core-accounts', () => ({ useAllAccounts: () => [], prefetchLedgerAccountPreview: mockPrefetch, useLedgerAccountPreview: vi.fn(), + useLedgerRekeyedScan: mockRekeyedScan, })) vi.mock('@modules/bottom-sheet', () => ({ @@ -140,6 +143,7 @@ describe('useLedgerSelectAccountsScreen', () => { mockConnect.mockResolvedValue(transport) mockDisconnectTransport.mockResolvedValue(undefined) mockGetProviderRegistry.mockReturnValue({ connect: mockConnect }) + mockRekeyedScan.mockReturnValue({ rekeyed: [], isScanning: false }) }) it('exposes the route accounts unchanged on initial render', () => { @@ -425,4 +429,56 @@ describe('useLedgerSelectAccountsScreen', () => { ], }) }) + + it('exposes derived + scanned rekeyed as selectableAccounts', () => { + mockRekeyedScan.mockReturnValue({ + rekeyed: [ + { + kind: 'rekeyed', + address: 'REKEYED_A', + authAccount: { + address: 'AAA111', + publicKey: new Uint8Array([1]), + accountIndex: 0, + }, + }, + ], + isScanning: true, + }) + + const { result } = renderHook(() => useLedgerSelectAccountsScreen()) + + expect(result.current.isScanning).toBe(true) + const kinds = result.current.selectableAccounts.map(s => s.kind) + expect(kinds).toEqual(['derived', 'derived', 'rekeyed']) + }) + + it('navigates with the rekeyed selectable and auto-included auth account', () => { + const auth = { + address: 'AAA111', + publicKey: new Uint8Array([1]), + accountIndex: 0, + } + mockRekeyedScan.mockReturnValue({ + rekeyed: [{ kind: 'rekeyed', address: 'REKEYED_A', authAccount: auth }], + isScanning: false, + }) + + const { result } = renderHook(() => useLedgerSelectAccountsScreen()) + + act(() => { + result.current.toggleSelection('REKEYED_A') + }) + act(() => { + result.current.handleContinue() + }) + + const arg = mockNavigate.mock.calls.find( + c => c[0] === 'LedgerVerify', + )?.[1] as { selectedAccounts: unknown[] } + expect(arg.selectedAccounts).toEqual([ + { kind: 'rekeyed', address: 'REKEYED_A', authAccount: auth }, + { kind: 'derived', account: auth }, + ]) + }) }) diff --git a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.tsx b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.tsx index a592b2f7e..d3ced193a 100644 --- a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.tsx +++ b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.tsx @@ -17,6 +17,7 @@ import { getProvider } from '@perawallet/wallet-extension-provider' import { useAllAccounts, prefetchLedgerAccountPreview, + useLedgerRekeyedScan, type LedgerSelectableAccount, } from '@perawallet/wallet-core-accounts' import type { LedgerAccount } from '@perawallet/wallet-core-ledger' @@ -45,6 +46,8 @@ type LedgerSelectAccountsRouteProp = RouteProp< type UseLedgerSelectAccountsScreenResult = { accounts: LedgerAccount[] + selectableAccounts: LedgerSelectableAccount[] + isScanning: boolean selectedAddresses: Set isAllSelected: boolean areAllImported: boolean @@ -119,15 +122,44 @@ export const useLedgerSelectAccountsScreen = }) }, [accounts, queryClient, algokit, network]) + const { rekeyed, isScanning } = useLedgerRekeyedScan(accounts) + + const selectableAccounts = useMemo( + () => [ + ...accounts.map( + (account): LedgerSelectableAccount => ({ + kind: 'derived', + account, + }), + ), + ...rekeyed, + ], + [accounts, rekeyed], + ) + + const selectableByAddress = useMemo(() => { + const m = new Map() + for (const s of selectableAccounts) { + m.set( + s.kind === 'derived' ? s.account.address : s.address, + s, + ) + } + return m + }, [selectableAccounts]) + const alreadyImportedAddresses = useMemo(() => { return new Set(allAccounts.map(acc => acc.address)) }, [allAccounts]) const newAccounts = useMemo(() => { - return accounts.filter( - acc => !alreadyImportedAddresses.has(acc.address), + return selectableAccounts.filter( + s => + !alreadyImportedAddresses.has( + s.kind === 'derived' ? s.account.address : s.address, + ), ) - }, [accounts, alreadyImportedAddresses]) + }, [selectableAccounts, alreadyImportedAddresses]) const isAllSelected = newAccounts.length > 0 && @@ -155,30 +187,50 @@ export const useLedgerSelectAccountsScreen = setSelectedAddresses(new Set()) } else { setSelectedAddresses( - new Set(newAccounts.map(acc => acc.address)), + new Set( + newAccounts.map(s => + s.kind === 'derived' + ? s.account.address + : s.address, + ), + ), ) } }, [isAllSelected, newAccounts]) const handleContinue = useCallback(() => { - const selected = accounts.filter(acc => - selectedAddresses.has(acc.address), + const selected = selectableAccounts.filter(s => + selectedAddresses.has( + s.kind === 'derived' ? s.account.address : s.address, + ), ) if (selected.length === 0) return - const selectedAccounts: LedgerSelectableAccount[] = selected.map( - account => ({ kind: 'derived', account }), + const result: LedgerSelectableAccount[] = [...selected] + const present = new Set( + selected.map(s => + s.kind === 'derived' ? s.account.address : s.address, + ), ) + for (const s of selected) { + if ( + s.kind === 'rekeyed' && + !present.has(s.authAccount.address) + ) { + present.add(s.authAccount.address) + result.push({ kind: 'derived', account: s.authAccount }) + } + } navigation.navigate('LedgerVerify', { deviceId, deviceName, transportType, - selectedAccounts, + selectedAccounts: result, }) }, [ - accounts, + selectableAccounts, selectedAddresses, deviceId, deviceName, @@ -234,17 +286,23 @@ export const useLedgerSelectAccountsScreen = const handleInfoPress = useCallback( (address: string, accountIndex: number) => { + const selectable = selectableByAddress.get(address) + const title = + selectable?.kind === 'rekeyed' + ? t('ledger.select_accounts.rekeyed_account_title') + : undefined void request({ contents: ( ), options: { size: 'lg' }, }) }, - [request], + [request, selectableByAddress, t], ) const areAllImported = newAccounts.length === 0 @@ -253,6 +311,8 @@ export const useLedgerSelectAccountsScreen = return { accounts, + selectableAccounts, + isScanning, selectedAddresses, isAllSelected, areAllImported, From 0315cfe8188297c810d3b374c5258027f1dbfe0d Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 18 May 2026 15:20:05 +0100 Subject: [PATCH 25/39] fix(ledger): robust isAllSelected under progressive rekeyed append; cover auth-dedup --- .../useLedgerSelectAccountsScreen.spec.ts | 44 +++++++++++++++++++ .../useLedgerSelectAccountsScreen.tsx | 6 ++- 2 files changed, 49 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 a4780a5f7..f484fe9dc 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 @@ -481,4 +481,48 @@ describe('useLedgerSelectAccountsScreen', () => { { kind: 'derived', account: auth }, ]) }) + + it('does not double-include the auth account when it is also explicitly selected', () => { + const auth = { + address: 'AAA111', + publicKey: new Uint8Array([1]), + accountIndex: 0, + } + mockRekeyedScan.mockReturnValue({ + rekeyed: [{ kind: 'rekeyed', address: 'REKEYED_A', authAccount: auth }], + isScanning: false, + }) + + const { result } = renderHook(() => useLedgerSelectAccountsScreen()) + + act(() => { + result.current.toggleSelection('REKEYED_A') + }) + act(() => { + result.current.toggleSelection('AAA111') + }) + act(() => { + result.current.handleContinue() + }) + + const arg = mockNavigate.mock.calls.find( + c => c[0] === 'LedgerVerify', + )?.[1] as { selectedAccounts: unknown[] } + const authDerivedCount = arg.selectedAccounts.filter( + (s: unknown) => + (s as { kind: string; account?: { address: string } }) + .kind === 'derived' && + (s as { account: { address: string } }).account.address === + 'AAA111', + ).length + expect(authDerivedCount).toBe(1) + expect( + arg.selectedAccounts.some( + (s: unknown) => + (s as { kind: string; address?: string }).kind === + 'rekeyed' && + (s as { address: string }).address === 'REKEYED_A', + ), + ).toBe(true) + }) }) diff --git a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.tsx b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.tsx index d3ced193a..9fd6a659c 100644 --- a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.tsx +++ b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.tsx @@ -163,7 +163,11 @@ export const useLedgerSelectAccountsScreen = const isAllSelected = newAccounts.length > 0 && - selectedAddresses.size === newAccounts.length + newAccounts.every(s => + selectedAddresses.has( + s.kind === 'derived' ? s.account.address : s.address, + ), + ) const toggleSelection = useCallback( (address: string) => { From 3fbbbbcd5e688bd468ab7ebf5cace0614c48f7cc Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 18 May 2026 15:21:30 +0100 Subject: [PATCH 26/39] feat(ledger): i18n for rekeyed account import --- apps/mobile/src/i18n/locales/en.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/i18n/locales/en.json b/apps/mobile/src/i18n/locales/en.json index 23edab48c..9e09f7ac9 100644 --- a/apps/mobile/src/i18n/locales/en.json +++ b/apps/mobile/src/i18n/locales/en.json @@ -2130,7 +2130,10 @@ "cta_other": "Verify selected accounts", "address_sheet_title": "Account address", "no_new_accounts": "All accounts on this device are already imported.", - "find_another_wallet": "Find another account" + "find_another_wallet": "Find another account", + "rekeyed_label": "Rekeyed", + "rekeyed_account_title": "Rekeyed account", + "scanning_rekeyed": "Scanning for rekeyed accounts…" }, "account_info": { "account_details": "Account details", From 847d76dfa967136441afcca8ce706aef5d19ef19 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 18 May 2026 15:23:21 +0100 Subject: [PATCH 27/39] style(ledger): formatter and copyright-header fixups (sub-project B) --- .../LedgerAccountInfoContent.tsx | 9 ++- .../useLedgerSelectAccountsScreen.spec.ts | 12 ++-- .../useLedgerSelectAccountsScreen.tsx | 5 +- .../__tests__/useLedgerVerifyScreen.spec.ts | 56 +++++++++---------- .../useLedgerVerifyScreen.ts | 4 +- .../__tests__/useLedgerRekeyedScan.test.ts | 4 +- .../src/hooks/useLedgerRekeyedScan.ts | 4 +- 7 files changed, 45 insertions(+), 49 deletions(-) diff --git a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoContent.tsx b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoContent.tsx index c64663676..6a4497b08 100644 --- a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoContent.tsx +++ b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoContent.tsx @@ -50,8 +50,13 @@ export const LedgerAccountInfoContent = ({ const { t } = useLanguage() const { dismiss } = useBottomSheetResult() const { preferredCurrency } = useCurrency() - const { title: resolvedTitle, items, isLoading, isError, refetch } = - useLedgerAccountInfoContent(address, accountIndex, title) + const { + title: resolvedTitle, + items, + isLoading, + isError, + refetch, + } = useLedgerAccountInfoContent(address, accountIndex, title) const renderItem = useCallback( ({ item }: { item: LedgerInfoListItem }) => { 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 f484fe9dc..0d170c460 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 @@ -460,7 +460,9 @@ describe('useLedgerSelectAccountsScreen', () => { accountIndex: 0, } mockRekeyedScan.mockReturnValue({ - rekeyed: [{ kind: 'rekeyed', address: 'REKEYED_A', authAccount: auth }], + rekeyed: [ + { kind: 'rekeyed', address: 'REKEYED_A', authAccount: auth }, + ], isScanning: false, }) @@ -489,7 +491,9 @@ describe('useLedgerSelectAccountsScreen', () => { accountIndex: 0, } mockRekeyedScan.mockReturnValue({ - rekeyed: [{ kind: 'rekeyed', address: 'REKEYED_A', authAccount: auth }], + rekeyed: [ + { kind: 'rekeyed', address: 'REKEYED_A', authAccount: auth }, + ], isScanning: false, }) @@ -510,8 +514,8 @@ describe('useLedgerSelectAccountsScreen', () => { )?.[1] as { selectedAccounts: unknown[] } const authDerivedCount = arg.selectedAccounts.filter( (s: unknown) => - (s as { kind: string; account?: { address: string } }) - .kind === 'derived' && + (s as { kind: string; account?: { address: string } }).kind === + 'derived' && (s as { account: { address: string } }).account.address === 'AAA111', ).length diff --git a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.tsx b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.tsx index 9fd6a659c..dee1d68c4 100644 --- a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.tsx +++ b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.tsx @@ -140,10 +140,7 @@ export const useLedgerSelectAccountsScreen = const selectableByAddress = useMemo(() => { const m = new Map() for (const s of selectableAccounts) { - m.set( - s.kind === 'derived' ? s.account.address : s.address, - s, - ) + m.set(s.kind === 'derived' ? s.account.address : s.address, s) } return m }, [selectableAccounts]) diff --git a/apps/mobile/src/modules/ledger/screens/LedgerVerifyScreen/__tests__/useLedgerVerifyScreen.spec.ts b/apps/mobile/src/modules/ledger/screens/LedgerVerifyScreen/__tests__/useLedgerVerifyScreen.spec.ts index d9db9d526..b51d657a9 100644 --- a/apps/mobile/src/modules/ledger/screens/LedgerVerifyScreen/__tests__/useLedgerVerifyScreen.spec.ts +++ b/apps/mobile/src/modules/ledger/screens/LedgerVerifyScreen/__tests__/useLedgerVerifyScreen.spec.ts @@ -20,25 +20,22 @@ import { vi.mock('@perawallet/wallet-core-accounts', async importOriginal => { const actual = - await importOriginal() + await importOriginal< + typeof import('@perawallet/wallet-core-accounts') + >() return { ...actual } }) import { useLedgerVerifyScreen } from '../useLedgerVerifyScreen' -const { - mockVerify, - mockExit, - mockSetConfetti, - mockConnect, - mockDisconnect, -} = vi.hoisted(() => ({ - mockVerify: vi.fn(), - mockExit: vi.fn(), - mockSetConfetti: vi.fn(), - mockConnect: vi.fn(), - mockDisconnect: vi.fn(), -})) +const { mockVerify, mockExit, mockSetConfetti, mockConnect, mockDisconnect } = + vi.hoisted(() => ({ + mockVerify: vi.fn(), + mockExit: vi.fn(), + mockSetConfetti: vi.fn(), + mockConnect: vi.fn(), + mockDisconnect: vi.fn(), + })) vi.mock('@hooks/useAppNavigation', () => ({ useAppNavigation: () => ({ navigate: vi.fn() }), @@ -71,7 +68,9 @@ vi.mock('@perawallet/wallet-core-ledger', () => ({ classifyLedgerError: (e: unknown) => e as Error, })) -const routeParams = vi.hoisted(() => ({ current: {} as Record })) +const routeParams = vi.hoisted(() => ({ + current: {} as Record, +})) vi.mock('@react-navigation/native', () => ({ useRoute: () => ({ params: routeParams.current }), })) @@ -113,9 +112,7 @@ describe('useLedgerVerifyScreen', () => { const { result } = renderHook(() => useLedgerVerifyScreen()) - await waitFor(() => - expect(result.current.areAllVerified).toBe(true), - ) + await waitFor(() => expect(result.current.areAllVerified).toBe(true)) expect(mockVerify).toHaveBeenCalledTimes(1) expect(mockVerify).toHaveBeenCalledWith(expect.anything(), 0) expect(result.current.verifyTargets).toEqual([d0]) @@ -123,11 +120,12 @@ describe('useLedgerVerifyScreen', () => { it('handleAdd imports derived as hardware, rekeyed as watch+rekeyAddress, auto-includes auth, skips already-imported and invalid', async () => { const d0 = derived('LEDGER0', 0) - useAccountsStore - .getState() - .setAccounts([ - { type: AccountTypes.watch, address: 'ALREADY' } as WalletAccount, - ]) + useAccountsStore.getState().setAccounts([ + { + type: AccountTypes.watch, + address: 'ALREADY', + } as WalletAccount, + ]) routeParams.current = { deviceId: 'dev', deviceName: 'Nano', @@ -140,9 +138,7 @@ describe('useLedgerVerifyScreen', () => { } const { result } = renderHook(() => useLedgerVerifyScreen()) - await waitFor(() => - expect(result.current.areAllVerified).toBe(true), - ) + await waitFor(() => expect(result.current.areAllVerified).toBe(true)) act(() => { result.current.handleAdd() @@ -154,9 +150,7 @@ describe('useLedgerVerifyScreen', () => { expect(hw?.type).toBe(AccountTypes.hardware) expect(watch?.type).toBe(AccountTypes.watch) expect(watch?.rekeyAddress).toBe('LEDGER0') - expect( - accounts.filter(a => a.address === 'ALREADY'), - ).toHaveLength(1) + expect(accounts.filter(a => a.address === 'ALREADY')).toHaveLength(1) expect(accounts.find(a => a.address === '!!bad')).toBeUndefined() expect(mockExit).toHaveBeenCalledTimes(1) expect(mockSetConfetti).toHaveBeenCalledWith(true) @@ -186,6 +180,8 @@ describe('useLedgerVerifyScreen', () => { const accounts = useAccountsStore.getState().accounts expect(accounts.find(a => a.address === 'REKEYED_X')).toBeUndefined() - expect(accounts.find(a => a.address === '!!invalidauth')).toBeUndefined() + expect( + accounts.find(a => a.address === '!!invalidauth'), + ).toBeUndefined() }) }) diff --git a/apps/mobile/src/modules/ledger/screens/LedgerVerifyScreen/useLedgerVerifyScreen.ts b/apps/mobile/src/modules/ledger/screens/LedgerVerifyScreen/useLedgerVerifyScreen.ts index d388bfad3..39f1b412a 100644 --- a/apps/mobile/src/modules/ledger/screens/LedgerVerifyScreen/useLedgerVerifyScreen.ts +++ b/apps/mobile/src/modules/ledger/screens/LedgerVerifyScreen/useLedgerVerifyScreen.ts @@ -214,9 +214,7 @@ export const useLedgerVerifyScreen = (): UseLedgerVerifyScreenResult => { setAccounts([...current, ...batch]) - const firstDerived = selectedAccounts.find( - s => s.kind === 'derived', - ) + const firstDerived = selectedAccounts.find(s => s.kind === 'derived') const selectedAddress = firstDerived ? firstDerived.account.address : batch[0].address diff --git a/packages/accounts/src/hooks/__tests__/useLedgerRekeyedScan.test.ts b/packages/accounts/src/hooks/__tests__/useLedgerRekeyedScan.test.ts index 2ffe07be5..f94753a38 100644 --- a/packages/accounts/src/hooks/__tests__/useLedgerRekeyedScan.test.ts +++ b/packages/accounts/src/hooks/__tests__/useLedgerRekeyedScan.test.ts @@ -73,9 +73,7 @@ describe('useLedgerRekeyedScan', () => { it('reports isScanning while any query is pending and tolerates missing data', () => { const d0 = derived('LEDGER0', 0) - mocks.useQueries.mockReturnValue([ - { data: undefined, isPending: true }, - ]) + mocks.useQueries.mockReturnValue([{ data: undefined, isPending: true }]) const { result } = renderHook(() => useLedgerRekeyedScan([d0])) diff --git a/packages/accounts/src/hooks/useLedgerRekeyedScan.ts b/packages/accounts/src/hooks/useLedgerRekeyedScan.ts index 5c7342879..23e8d962e 100644 --- a/packages/accounts/src/hooks/useLedgerRekeyedScan.ts +++ b/packages/accounts/src/hooks/useLedgerRekeyedScan.ts @@ -45,9 +45,7 @@ export const useLedgerRekeyedScan = ( }) return useMemo(() => { - const derivedAddresses = new Set( - derivedAccounts.map(a => a.address), - ) + const derivedAddresses = new Set(derivedAccounts.map(a => a.address)) const importedAddresses = new Set(allAccounts.map(a => a.address)) const seen = new Set() const rekeyed: LedgerSelectableAccount[] = [] From 27d95b034544d83d8b9c77b32e4752af118d7922 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 18 May 2026 15:30:56 +0100 Subject: [PATCH 28/39] test(ledger): integration test for rekeyed-account import Adds end-to-end integration coverage for the Ledger rekeyed-account import flow: indexer scan surfaces a rekeyed account, user selects it, handleContinue auto-includes the auth Ledger account, LedgerVerifyScreen verifies only the hardware account, import persists a WatchAccount with rekeyAddress and a HardwareWalletAccount, and deriveAccountLogicalType returns RekeyedAuth. Also exposes a real (empty) HardwareWalletRegistry in the platform-driver unit mock so integration tests can register a fake Ledger BLE transport provider to drive on-device verification. --- .../ledger-rekeyed-account-import.test.tsx | 211 ++++++++++++++++++ apps/mobile/vitest.setup.ts | 5 + 2 files changed, 216 insertions(+) create mode 100644 apps/mobile/src/__integration__/ledger-rekeyed-account-import.test.tsx diff --git a/apps/mobile/src/__integration__/ledger-rekeyed-account-import.test.tsx b/apps/mobile/src/__integration__/ledger-rekeyed-account-import.test.tsx new file mode 100644 index 000000000..dc0633655 --- /dev/null +++ b/apps/mobile/src/__integration__/ledger-rekeyed-account-import.test.tsx @@ -0,0 +1,211 @@ +/* + 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 { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, +} from 'vitest' +import { fireEvent, screen, waitFor } from '@testing-library/react' + +import { server } from '@test-utils/msw-server' +import { renderWithNavigation } from '@test-utils/renderWithNavigation' +import { resetTestKeystore } from '@test-utils/algorand-keystore-test' +import { + resetTestDatabase, + seedAlgoAsset, + setupTestDatabase, + teardownTestDatabase, +} from '@test-utils/database-setup' +import { + AccountTypes, + AccountLogicalTypes, + deriveAccountLogicalType, + useAccountsStore, +} from '@perawallet/wallet-core-accounts' +import { + mockAlgodAccountInformation, + mockAlgodStatus, + mockIndexerSearchForAccounts, +} from '@perawallet/wallet-core-blockchain/test-handlers' +import { getProvider } from '@perawallet/wallet-extension-provider' +import { LedgerSelectAccountsScreen } from '@modules/ledger/screens/LedgerSelectAccountsScreen' +import { LedgerVerifyScreen } from '@modules/ledger/screens/LedgerVerifyScreen' + +import { HD_TEST_ADDRESS, ALGO25_TEST_ADDRESS } from './__fixtures__/onboarding' + +const SLOW_TEST_TIMEOUT_MS = 30000 +const LEDGER_ADDRESS = HD_TEST_ADDRESS +const REKEYED_ADDRESS = ALGO25_TEST_ADDRESS + +/** + * Register a no-op Ledger BLE transport provider for the duration of this test + * suite. The verify screen calls: + * getProvider().hardwareWalletRegistry.getProvider('ledger','ble').connect(deviceId) + * and then `transport.getAddress(accountIndex, true)`. + * Without a registered provider the screen throws `LedgerProviderNotFoundError` + * immediately and shows the error state — the add button never enables. + */ +const registerFakeLedgerProvider = () => { + getProvider().hardwareWalletRegistry.register({ + manufacturer: 'ledger', + transportType: 'ble', + scan: () => () => {}, + connect: async () => ({ + getAddress: async (accountIndex: number) => ({ + address: LEDGER_ADDRESS, + publicKey: new Uint8Array(32), + accountIndex, + }), + signTransaction: async () => new Uint8Array(64), + disconnect: async () => {}, + }), + isSupported: async () => false, + }) +} + +describe('Flow: Ledger rekeyed-account import', () => { + beforeAll(async () => { + server.listen({ onUnhandledRequest: 'warn' }) + await setupTestDatabase() + registerFakeLedgerProvider() + }) + afterEach(() => server.resetHandlers()) + afterAll(async () => { + server.close() + await teardownTestDatabase() + }) + + beforeEach(async () => { + await resetTestDatabase() + await seedAlgoAsset('mainnet') + resetTestKeystore() + useAccountsStore.getState().setAccounts([]) + + server.use( + mockAlgodAccountInformation({ + address: LEDGER_ADDRESS, + response: { amount: 1_000_000, 'min-balance': 100_000 }, + }), + mockAlgodAccountInformation({ + address: REKEYED_ADDRESS, + response: { + amount: 5_000_000, + 'min-balance': 100_000, + 'auth-addr': LEDGER_ADDRESS, + }, + }), + mockAlgodStatus({ response: { 'last-round': 100 } }), + mockIndexerSearchForAccounts({ + response: { accounts: [{ address: REKEYED_ADDRESS }] }, + }), + ) + }) + + it( + 'Given a discovered Ledger account with a rekeyed account, when the user selects only the rekeyed account and verifies, then it is imported as a watch account rekeyed to the auto-included Ledger account (RekeyedAuth)', + async () => { + renderWithNavigation( + LedgerSelectAccountsScreen, + 'LedgerSelectAccounts', + { + initialParams: { + deviceId: 'test-device-id', + deviceName: 'Ledger Nano X', + transportType: 'ble', + accounts: [ + { + address: LEDGER_ADDRESS, + publicKey: new Uint8Array([1]), + accountIndex: 0, + }, + ], + }, + additionalScreens: [ + { + name: 'LedgerVerify', + component: LedgerVerifyScreen, + }, + { + name: 'LedgerTroubleshooting', + component: () => null, + }, + ], + }, + ) + + // Wait for the rekeyed row to appear (indexer scan completes) + const rekeyedRow = await waitFor( + () => screen.getByTestId(`ledger_select_row_${REKEYED_ADDRESS}`), + { timeout: 10000 }, + ) + + // Select the rekeyed account + fireEvent.click(rekeyedRow) + + // Continue — the screen auto-includes the auth Ledger account + fireEvent.click( + screen.getByTestId('ledger_select_accounts_continue_button'), + ) + + // LedgerVerifyScreen: only the auth Ledger account (index 0) is + // verified — the rekeyed address itself has no device card + await waitFor( + () => + expect( + screen.getByTestId('ledger_verify_card_0'), + ).toBeTruthy(), + { timeout: 10000 }, + ) + expect(screen.queryByTestId('ledger_verify_card_1')).toBeNull() + + // Verification runs on mount via the fake transport; wait for the + // add button to become enabled + const addBtn = await waitFor( + () => { + const btn = screen.getByTestId( + 'ledger_verify_add_accounts_button', + ) as HTMLButtonElement + expect(btn.disabled).toBe(false) + return btn + }, + { timeout: 10000 }, + ) + + fireEvent.click(addBtn) + + // Assert the accounts are persisted with the correct types and + // that deriveAccountLogicalType returns RekeyedAuth + await waitFor( + () => { + const accounts = useAccountsStore.getState().accounts + const watch = accounts.find( + a => a.address === REKEYED_ADDRESS, + ) + const hw = accounts.find(a => a.address === LEDGER_ADDRESS) + expect(watch?.type).toBe(AccountTypes.watch) + expect(watch?.rekeyAddress).toBe(LEDGER_ADDRESS) + expect(hw?.type).toBe(AccountTypes.hardware) + expect( + deriveAccountLogicalType(watch!, accounts), + ).toBe(AccountLogicalTypes.RekeyedAuth) + }, + { timeout: 10000 }, + ) + }, + SLOW_TEST_TIMEOUT_MS, + ) +}) diff --git a/apps/mobile/vitest.setup.ts b/apps/mobile/vitest.setup.ts index d3bca96cf..24034aaf4 100644 --- a/apps/mobile/vitest.setup.ts +++ b/apps/mobile/vitest.setup.ts @@ -64,6 +64,11 @@ vi.mock('@perawallet/wallet-extension-platform-driver', () => ({ getBoolean: vi.fn().mockReturnValue(false), getString: vi.fn().mockReturnValue(''), }, + // Expose a real (empty) registry so integration tests that exercise + // hardware-wallet flows (e.g. LedgerVerifyScreen) can register a fake + // transport provider via `getProvider().hardwareWalletRegistry.register()`. + // Unit tests never call into the registry so providing an empty one is safe. + hardwareWalletRegistry: require('@perawallet/wallet-core-hardware-wallet').createHardwareWalletRegistry(), }), getPlatformServices: () => ({ keyValueStorage: { From 0e6593afba755a4809074dc6cff6dfe3de4e6b13 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 18 May 2026 15:41:33 +0100 Subject: [PATCH 29/39] style(ledger): copyright-header fixups for sub-project B test harness --- .../ledger-rekeyed-account-import.test.tsx | 9 +++++---- apps/mobile/vitest.setup.ts | 3 ++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/__integration__/ledger-rekeyed-account-import.test.tsx b/apps/mobile/src/__integration__/ledger-rekeyed-account-import.test.tsx index dc0633655..f4d59f1c8 100644 --- a/apps/mobile/src/__integration__/ledger-rekeyed-account-import.test.tsx +++ b/apps/mobile/src/__integration__/ledger-rekeyed-account-import.test.tsx @@ -149,7 +149,8 @@ describe('Flow: Ledger rekeyed-account import', () => { // Wait for the rekeyed row to appear (indexer scan completes) const rekeyedRow = await waitFor( - () => screen.getByTestId(`ledger_select_row_${REKEYED_ADDRESS}`), + () => + screen.getByTestId(`ledger_select_row_${REKEYED_ADDRESS}`), { timeout: 10000 }, ) @@ -199,9 +200,9 @@ describe('Flow: Ledger rekeyed-account import', () => { expect(watch?.type).toBe(AccountTypes.watch) expect(watch?.rekeyAddress).toBe(LEDGER_ADDRESS) expect(hw?.type).toBe(AccountTypes.hardware) - expect( - deriveAccountLogicalType(watch!, accounts), - ).toBe(AccountLogicalTypes.RekeyedAuth) + expect(deriveAccountLogicalType(watch!, accounts)).toBe( + AccountLogicalTypes.RekeyedAuth, + ) }, { timeout: 10000 }, ) diff --git a/apps/mobile/vitest.setup.ts b/apps/mobile/vitest.setup.ts index 24034aaf4..a12758008 100644 --- a/apps/mobile/vitest.setup.ts +++ b/apps/mobile/vitest.setup.ts @@ -68,7 +68,8 @@ vi.mock('@perawallet/wallet-extension-platform-driver', () => ({ // hardware-wallet flows (e.g. LedgerVerifyScreen) can register a fake // transport provider via `getProvider().hardwareWalletRegistry.register()`. // Unit tests never call into the registry so providing an empty one is safe. - hardwareWalletRegistry: require('@perawallet/wallet-core-hardware-wallet').createHardwareWalletRegistry(), + hardwareWalletRegistry: + require('@perawallet/wallet-core-hardware-wallet').createHardwareWalletRegistry(), }), getPlatformServices: () => ({ keyValueStorage: { From 9eef2a77e799f5f2112b717ddcb91a4dd9de37c0 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Tue, 19 May 2026 11:00:41 +0100 Subject: [PATCH 30/39] feat(accounts): expose per-asset usdPrice on LedgerAccountPreviewAsset --- .../src/hooks/__tests__/useLedgerAccountPreview.test.ts | 3 +++ packages/accounts/src/hooks/useLedgerAccountPreview.ts | 2 ++ packages/accounts/src/models/ledger-account-preview.ts | 2 ++ 3 files changed, 7 insertions(+) diff --git a/packages/accounts/src/hooks/__tests__/useLedgerAccountPreview.test.ts b/packages/accounts/src/hooks/__tests__/useLedgerAccountPreview.test.ts index f9a3da8ae..58fa5351b 100644 --- a/packages/accounts/src/hooks/__tests__/useLedgerAccountPreview.test.ts +++ b/packages/accounts/src/hooks/__tests__/useLedgerAccountPreview.test.ts @@ -89,6 +89,7 @@ describe('useLedgerAccountPreview', () => { expect(result.current.preview?.assets[0].isAlgo).toBe(true) expect(result.current.preview?.assets[0].unitName).toBe('ALGO') expect(result.current.preview?.assets[0].decimals).toBe(6) + expect(result.current.preview?.assets[0].usdPrice.toString()).toBe('2') }) it('includes ASA holdings with metadata, fiat value and verification tier', () => { @@ -143,6 +144,7 @@ describe('useLedgerAccountPreview', () => { expect(usdc?.verificationTier).toBe('verified') expect(usdc?.name).toBe('USDC') expect(usdc?.decimals).toBe(6) + expect(usdc?.usdPrice.toString()).toBe('1') }) it('falls back to asset-id string, empty unitName, unverified tier and decimals=0 when asset metadata is missing', () => { @@ -177,6 +179,7 @@ describe('useLedgerAccountPreview', () => { expect(asa?.fiatValue.toString()).toBe('0') expect(asa?.isAlgo).toBe(false) expect(asa?.decimals).toBe(0) + expect(asa?.usdPrice.toString()).toBe('0') }) it('reports rekeyedTo when the account is rekeyed', () => { diff --git a/packages/accounts/src/hooks/useLedgerAccountPreview.ts b/packages/accounts/src/hooks/useLedgerAccountPreview.ts index d60efbbf0..c0b94cc5f 100644 --- a/packages/accounts/src/hooks/useLedgerAccountPreview.ts +++ b/packages/accounts/src/hooks/useLedgerAccountPreview.ts @@ -66,6 +66,7 @@ export const useLedgerAccountPreview = ( decimals: ALGO_ASSET.decimals, amount: algoBalance, fiatValue: usdToPreferred(algoBalance.times(algoUsdPrice)), + usdPrice: algoUsdPrice, verificationTier: PeraAssetVerificationTier.verified, logo: undefined, isAlgo: true, @@ -86,6 +87,7 @@ export const useLedgerAccountPreview = ( decimals, amount, fiatValue: usdToPreferred(usdValue), + usdPrice, verificationTier: meta?.peraMetadata?.verificationTier ?? PeraAssetVerificationTier.unverified, diff --git a/packages/accounts/src/models/ledger-account-preview.ts b/packages/accounts/src/models/ledger-account-preview.ts index 4f9a63007..34c88ebf2 100644 --- a/packages/accounts/src/models/ledger-account-preview.ts +++ b/packages/accounts/src/models/ledger-account-preview.ts @@ -23,6 +23,8 @@ export type LedgerAccountPreviewAsset = { amount: Decimal /** Holding value in the user's preferred currency */ fiatValue: Decimal + /** Asset USD price (0 when unknown) */ + usdPrice: Decimal verificationTier: PeraAssetVerificationTier logo?: string isAlgo: boolean From 11f649cb187752278f30f95c36be25e256dacb2b Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Tue, 19 May 2026 11:15:32 +0100 Subject: [PATCH 31/39] fix(ledger): render account info sheet with canonical AccountDisplay + AccountAssetItemView Replace bespoke LedgerAccountInfoRows with canonical design-system components: - Account row now uses AccountDisplay (showChevron=false showAccountType) + CurrencyDisplay/PreferredCurrencyDisplay mirroring AccountWithBalance layout - Asset rows now use AccountAssetItemView for correct icon size and chip rendering - Rekey-address rows use AccountDisplay with synth WalletAccount objects - useLedgerAccountInfoContent item shapes updated to carry WalletAccount synth accounts, AssetWithAccountBalance for assets, and algoUsdPrice from preview - LedgerAccountInfoRows.tsx deleted; unit test spec and select-screen spec mock updated --- .../LedgerAccountInfoContent.tsx | 76 +++++--- .../LedgerAccountInfoRows.tsx | 167 ------------------ .../useLedgerAccountInfoContent.spec.ts | 165 ++++++++++++++++- .../LedgerAccountInfoContent/styles.ts | 24 ++- .../useLedgerAccountInfoContent.ts | 84 +++++++-- .../useLedgerSelectAccountsScreen.spec.ts | 21 +++ 6 files changed, 319 insertions(+), 218 deletions(-) delete mode 100644 apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoRows.tsx diff --git a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoContent.tsx b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoContent.tsx index 6a4497b08..13f9fd887 100644 --- a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoContent.tsx +++ b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoContent.tsx @@ -12,7 +12,6 @@ import { useCallback } from 'react' import { ActivityIndicator } from 'react-native' -import { useCurrency } from '@perawallet/wallet-core-currencies' import { PWView, PWText, @@ -21,18 +20,17 @@ import { PWButton, PWFlatList, } from '@components/core' +import { CurrencyDisplay } from '@components/CurrencyDisplay' +import { PreferredCurrencyDisplay } from '@components/PreferredCurrencyDisplay' import { useLanguage } from '@hooks/useLanguage' import { useBottomSheetResult } from '@modules/bottom-sheet' +import { AccountDisplay } from '@modules/accounts/components/AccountDisplay' +import { AccountAssetItemView } from '@modules/assets/components/AssetItem' +import { ALGO_ASSET, ALGO_ASSET_ID } from '@perawallet/wallet-core-assets' import { useLedgerAccountInfoContent, type LedgerInfoListItem, } from './useLedgerAccountInfoContent' -import { - LedgerSectionHeaderRow, - LedgerAccountDetailsRow, - LedgerAssetRow, - LedgerRekeyAddressRow, -} from './LedgerAccountInfoRows' import { useStyles } from './styles' export type LedgerAccountInfoContentProps = { @@ -49,7 +47,6 @@ export const LedgerAccountInfoContent = ({ const styles = useStyles() const { t } = useLanguage() const { dismiss } = useBottomSheetResult() - const { preferredCurrency } = useCurrency() const { title: resolvedTitle, items, @@ -62,31 +59,64 @@ export const LedgerAccountInfoContent = ({ ({ item }: { item: LedgerInfoListItem }) => { switch (item.kind) { case 'sectionHeader': - return + return ( + + {item.title} + + ) + case 'account': return ( - + + + + + + + ) + case 'asset': return ( - ) + case 'rekeyAddress': - return + return ( + + ) } }, - [t, preferredCurrency], + [styles], ) return ( diff --git a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoRows.tsx b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoRows.tsx deleted file mode 100644 index db23bf17e..000000000 --- a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoRows.tsx +++ /dev/null @@ -1,167 +0,0 @@ -/* - 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 type { Decimal } from 'decimal.js' -import { truncateAlgorandAddress } from '@perawallet/wallet-core-shared' -import { ALGO_ASSET } from '@perawallet/wallet-core-assets' -import type { LedgerAccountPreviewAsset } from '@perawallet/wallet-core-accounts' -import { PWView, PWText } from '@components/core' -import { CurrencyDisplay } from '@components/CurrencyDisplay' -import { AssetIcon } from '@modules/assets/components/AssetIcon' -import { AssetTierChip } from '@modules/assets/components/AssetTierChip' -import LightLedgerAccountIcon from '@assets/icons/accounts/light/ledger-account.svg' -import { useStyles } from './styles' - -export const LedgerSectionHeaderRow = ({ title }: { title: string }) => { - const styles = useStyles() - return ( - - {title} - - ) -} - -type AccountRowProps = { - address: string - algoBalance: Decimal - fiatValue: Decimal - label: string - preferredCurrency: string -} - -export const LedgerAccountDetailsRow = ({ - address, - algoBalance, - fiatValue, - label, - preferredCurrency, -}: AccountRowProps) => { - const styles = useStyles() - return ( - - - - - {truncateAlgorandAddress(address)} - - - {label} - - - - - - - - ) -} - -export const LedgerAssetRow = ({ - asset, - preferredCurrency, -}: { - asset: LedgerAccountPreviewAsset - preferredCurrency: string -}) => { - const styles = useStyles() - const iconAsset = asset.isAlgo - ? ALGO_ASSET - : { - assetId: asset.assetId, - name: asset.name, - unitName: asset.unitName, - decimals: 0, - } - return ( - - - - - {asset.name} - - - - - - - - - ) -} - -export const LedgerRekeyAddressRow = ({ address }: { address: string }) => { - const styles = useStyles() - return ( - - - - {truncateAlgorandAddress(address)} - - - - ) -} diff --git a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/__tests__/useLedgerAccountInfoContent.spec.ts b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/__tests__/useLedgerAccountInfoContent.spec.ts index 0b08fac47..4bad437f2 100644 --- a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/__tests__/useLedgerAccountInfoContent.spec.ts +++ b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/__tests__/useLedgerAccountInfoContent.spec.ts @@ -13,12 +13,20 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { renderHook } from '@testing-library/react' import { Decimal } from 'decimal.js' +import { AccountTypes } from '@perawallet/wallet-core-accounts' import { useLedgerAccountInfoContent } from '../useLedgerAccountInfoContent' const mocks = vi.hoisted(() => ({ useLedgerAccountPreview: vi.fn() })) vi.mock('@perawallet/wallet-core-accounts', () => ({ useLedgerAccountPreview: mocks.useLedgerAccountPreview, + AccountTypes: { + algo25: 'algo25', + hdWallet: 'hdWallet', + hardware: 'hardware', + multisig: 'multisig', + watch: 'watch', + }, })) vi.mock('@hooks/useLanguage', () => ({ useLanguage: () => ({ t: (k: string) => k }), @@ -28,12 +36,26 @@ const baseAsset = { assetId: '0', name: 'Algo', unitName: 'ALGO', + decimals: 6, amount: new Decimal(5), fiatValue: new Decimal(10), - verificationTier: 'verified', + usdPrice: new Decimal(2), + verificationTier: 'verified' as const, isAlgo: true, } +const asaAsset = { + assetId: '12345', + name: 'USDC', + unitName: 'USDC', + decimals: 6, + amount: new Decimal(100), + fiatValue: new Decimal(100), + usdPrice: new Decimal(1), + verificationTier: 'trusted' as const, + isAlgo: false, +} + beforeEach(() => { vi.clearAllMocks() }) @@ -108,8 +130,10 @@ describe('useLedgerAccountInfoContent', () => { expect(rekeyAddressItems).toHaveLength(1) expect(rekeyAddressItems[0]).toMatchObject({ kind: 'rekeyAddress', - address: 'AUTH', }) + if (rekeyAddressItems[0].kind === 'rekeyAddress') { + expect(rekeyAddressItems[0].account.address).toBe('AUTH') + } }) it('emits a "can sign for these" rekey section when canSignFor', () => { @@ -132,7 +156,7 @@ describe('useLedgerAccountInfoContent', () => { const rekeyAddresses = result.current.items .filter(i => i.kind === 'rekeyAddress') - .map(i => (i.kind === 'rekeyAddress' ? i.address : '')) + .map(i => (i.kind === 'rekeyAddress' ? i.account.address : '')) expect(rekeyAddresses).toEqual(['R1', 'R2']) }) @@ -151,13 +175,13 @@ describe('useLedgerAccountInfoContent', () => { expect(result.current.title).toBe('Rekeyed account') }) - it('carries Decimal instances for algoBalance and fiatValue on the account item', () => { + it('carries Decimal instances for algoBalance and algoUsdPrice on the account item', () => { mocks.useLedgerAccountPreview.mockReturnValue({ preview: { address: 'ADDR', algoBalance: new Decimal('408.2'), totalFiatValue: new Decimal('48.45'), - assets: [baseAsset], + assets: [{ ...baseAsset, usdPrice: new Decimal('0.6') }], rekey: { kind: 'none' }, }, isLoading: false, @@ -174,8 +198,135 @@ describe('useLedgerAccountInfoContent', () => { if (acct?.kind === 'account') { expect(acct.algoBalance).toBeInstanceOf(Decimal) expect(acct.algoBalance.toString()).toBe('408.2') - expect(acct.fiatValue).toBeInstanceOf(Decimal) - expect(acct.fiatValue.toString()).toBe('48.45') + expect(acct.algoUsdPrice).toBeInstanceOf(Decimal) + expect(acct.algoUsdPrice.toString()).toBe('0.6') + } + }) + + it('builds a hardware synth account on the account item when no rekey', () => { + mocks.useLedgerAccountPreview.mockReturnValue({ + preview: { + address: 'MYADDR', + algoBalance: new Decimal(1), + totalFiatValue: new Decimal(0), + assets: [baseAsset], + rekey: { kind: 'none' }, + }, + isLoading: false, + isError: false, + refetch: vi.fn(), + }) + + const { result } = renderHook(() => + useLedgerAccountInfoContent('MYADDR', 2), + ) + + const acct = result.current.items.find(i => i.kind === 'account') + if (acct?.kind === 'account') { + expect(acct.account.type).toBe(AccountTypes.hardware) + expect(acct.account.address).toBe('MYADDR') + if (acct.account.type === AccountTypes.hardware) { + expect(acct.account.hardwareDetails.accountIndex).toBe(2) + expect(acct.account.hardwareDetails.manufacturer).toBe('ledger') + } + } + }) + + it('builds a watch+rekeyAddress synth account on the account item when rekeyedTo', () => { + mocks.useLedgerAccountPreview.mockReturnValue({ + preview: { + address: 'WATCH_ADDR', + algoBalance: new Decimal(0), + totalFiatValue: new Decimal(0), + assets: [baseAsset], + rekey: { kind: 'rekeyedTo', authAddress: 'AUTH_ADDR' }, + }, + isLoading: false, + isError: false, + refetch: vi.fn(), + }) + + const { result } = renderHook(() => + useLedgerAccountInfoContent('WATCH_ADDR', 0), + ) + + const acct = result.current.items.find(i => i.kind === 'account') + if (acct?.kind === 'account') { + expect(acct.account.type).toBe(AccountTypes.watch) + expect(acct.account.address).toBe('WATCH_ADDR') + expect(acct.account.rekeyAddress).toBe('AUTH_ADDR') } }) + + it('adapts LedgerAccountPreviewAsset to AssetWithAccountBalance shape on asset items', () => { + mocks.useLedgerAccountPreview.mockReturnValue({ + preview: { + address: 'ADDR', + algoBalance: new Decimal(5), + totalFiatValue: new Decimal(105), + assets: [baseAsset, asaAsset], + rekey: { kind: 'none' }, + }, + isLoading: false, + isError: false, + refetch: vi.fn(), + }) + + const { result } = renderHook(() => + useLedgerAccountInfoContent('ADDR', 0), + ) + + const assetItems = result.current.items.filter(i => i.kind === 'asset') + expect(assetItems).toHaveLength(2) + + if (assetItems[0].kind === 'asset') { + expect(assetItems[0].accountBalance.assetId).toBe('0') + expect(assetItems[0].accountBalance.amount).toBeInstanceOf(Decimal) + expect(assetItems[0].accountBalance.amount.toString()).toBe('5') + expect(assetItems[0].usdPrice).toBeInstanceOf(Decimal) + expect(assetItems[0].usdPrice.toString()).toBe('2') + } + + if (assetItems[1].kind === 'asset') { + expect(assetItems[1].accountBalance.assetId).toBe('12345') + expect(assetItems[1].usdPrice.toString()).toBe('1') + } + }) + + it('section ordering: account_details → account → assets → asset(s) → rekey header + rekey rows', () => { + mocks.useLedgerAccountPreview.mockReturnValue({ + preview: { + address: 'ADDR', + algoBalance: new Decimal(1), + totalFiatValue: new Decimal(1), + assets: [baseAsset, asaAsset], + rekey: { kind: 'canSignFor', addresses: ['R1'] }, + }, + isLoading: false, + isError: false, + refetch: vi.fn(), + }) + + const { result } = renderHook(() => + useLedgerAccountInfoContent('ADDR', 0), + ) + + const kinds = result.current.items.map(i => i.kind) + expect(kinds).toEqual([ + 'sectionHeader', // account_details + 'account', + 'sectionHeader', // assets + 'asset', // ALGO + 'asset', // USDC + 'sectionHeader', // can_sign_for + 'rekeyAddress', + ]) + + const headers = result.current.items + .filter(i => i.kind === 'sectionHeader') + .map(i => (i.kind === 'sectionHeader' ? i.title : '')) + expect(headers[0]).toBe('ledger.account_info.account_details') + expect(headers[1]).toBe('ledger.account_info.assets') + expect(headers[2]).toBe('ledger.account_info.can_sign_for') + }) }) diff --git a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/styles.ts b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/styles.ts index 985b120e3..d2502680c 100644 --- a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/styles.ts +++ b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/styles.ts @@ -25,19 +25,25 @@ export const useStyles = makeStyles(theme => ({ marginBottom: theme.spacing.sm, color: theme.colors.textMain, }, - row: { + /** Account-details row — mirrors AccountWithBalance layout */ + accountRow: { flexDirection: 'row', alignItems: 'center', - paddingVertical: theme.spacing.md, - gap: theme.spacing.md, - }, - rowText: { - flex: 1, - gap: theme.spacing.xxs, + justifyContent: 'space-between', + gap: theme.spacing.sm, + paddingVertical: theme.spacing.sm, }, - rowTrailing: { + balanceContainer: { + gap: theme.spacing.xs, alignItems: 'flex-end', - gap: theme.spacing.xxs, + flexShrink: 0, + }, + fiatBalance: { + color: theme.colors.textGray, + }, + /** Rekey-address rows — light vertical padding so they read as a list */ + rekeyRow: { + paddingVertical: theme.spacing.sm, }, secondary: { color: theme.colors.textGray, diff --git a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/useLedgerAccountInfoContent.ts b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/useLedgerAccountInfoContent.ts index a6f818133..09ffbc0a0 100644 --- a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/useLedgerAccountInfoContent.ts +++ b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/useLedgerAccountInfoContent.ts @@ -11,11 +11,15 @@ */ import { useMemo } from 'react' -import type { Decimal } from 'decimal.js' +import { Decimal } from 'decimal.js' import { useLedgerAccountPreview, - type LedgerAccountPreviewAsset, + AccountTypes, + type WalletAccount, + type HardwareWalletAccount, + type WatchAccount, } from '@perawallet/wallet-core-accounts' +import type { AssetWithAccountBalance } from '@perawallet/wallet-core-accounts' import { useLanguage } from '@hooks/useLanguage' export type LedgerInfoListItem = @@ -23,12 +27,17 @@ export type LedgerInfoListItem = | { kind: 'account' key: string - address: string + account: WalletAccount algoBalance: Decimal - fiatValue: Decimal + algoUsdPrice: Decimal } - | { kind: 'asset'; key: string; asset: LedgerAccountPreviewAsset } - | { kind: 'rekeyAddress'; key: string; address: string } + | { + kind: 'asset' + key: string + accountBalance: AssetWithAccountBalance + usdPrice: Decimal + } + | { kind: 'rekeyAddress'; key: string; account: WalletAccount } type UseLedgerAccountInfoContentResult = { title: string @@ -51,6 +60,33 @@ export const useLedgerAccountInfoContent = ( const items = useMemo(() => { if (!preview) return [] + // Build the synth account for the sheet's own address. + // If the account is rekeyed to an auth address, render it as a watch + // account with rekeyAddress set. Otherwise render it as a hardware + // Ledger account so AccountDisplay/AccountIcon show the correct icon. + const synthAccount: WalletAccount = + preview.rekey.kind === 'rekeyedTo' + ? ({ + type: AccountTypes.watch, + address: preview.address, + rekeyAddress: preview.rekey.authAddress, + } satisfies WatchAccount) + : ({ + type: AccountTypes.hardware, + address: preview.address, + hardwareDetails: { + manufacturer: 'ledger', + deviceId: '', + deviceName: '', + accountIndex, + transportType: 'ble', + }, + } satisfies HardwareWalletAccount) + + // Extract usdPrice from the ALGO preview asset for the account row. + const algoPreviewAsset = preview.assets.find(a => a.isAlgo) + const algoUsdPrice = algoPreviewAsset?.usdPrice ?? new Decimal(0) + const list: LedgerInfoListItem[] = [ { kind: 'sectionHeader', @@ -60,9 +96,9 @@ export const useLedgerAccountInfoContent = ( { kind: 'account', key: 'account', - address: preview.address, + account: synthAccount, algoBalance: preview.algoBalance, - fiatValue: preview.totalFiatValue, + algoUsdPrice, }, { kind: 'sectionHeader', @@ -73,12 +109,31 @@ export const useLedgerAccountInfoContent = ( (asset): LedgerInfoListItem => ({ kind: 'asset', key: `asset-${asset.assetId}`, - asset, + accountBalance: { + assetId: asset.assetId, + amount: asset.amount, + algoValue: new Decimal(0), + } satisfies AssetWithAccountBalance, + usdPrice: asset.usdPrice, }), ), ] if (preview.rekey.kind === 'rekeyedTo') { + // Build a synth hardware account for the auth address (it's a Ledger + // signing key). accountIndex 0 is a safe placeholder — AccountDisplay + // only reads type/address/name for display. + const authSynthAccount: HardwareWalletAccount = { + type: AccountTypes.hardware, + address: preview.rekey.authAddress, + hardwareDetails: { + manufacturer: 'ledger', + deviceId: '', + deviceName: '', + accountIndex: 0, + transportType: 'ble', + }, + } list.push( { kind: 'sectionHeader', @@ -88,7 +143,7 @@ export const useLedgerAccountInfoContent = ( { kind: 'rekeyAddress', key: `rekey-${preview.rekey.authAddress}`, - address: preview.rekey.authAddress, + account: authSynthAccount, }, ) } else if (preview.rekey.kind === 'canSignFor') { @@ -98,16 +153,21 @@ export const useLedgerAccountInfoContent = ( title: t('ledger.account_info.can_sign_for'), }) preview.rekey.addresses.forEach(addr => { + // These rekeyed addresses are watch accounts (no key on this device). + const watchSynth: WatchAccount = { + type: AccountTypes.watch, + address: addr, + } list.push({ kind: 'rekeyAddress', key: `rekey-${addr}`, - address: addr, + account: watchSynth, }) }) } return list - }, [preview, t]) + }, [preview, t, accountIndex]) return { title: titleOverride ?? `Ledger #${accountIndex}`, 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 0d170c460..99e708236 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 @@ -90,11 +90,32 @@ const { mockPrefetch, mockRequest, mockQueryClient, mockRekeyedScan } = // useLedgerAccountPreview is included because the screen hook imports // LedgerAccountInfoContent (whose hook chain references it) at module load; // it is never invoked in these specs (the sheet content is not rendered). +// AccountTypes / AccountLogicalTypes / useAccountLogicalType / useRekeyTransition +// are needed because useLedgerAccountInfoContent → AccountDisplay → +// useAccountTypeLabel pulls these in at module evaluation time. vi.mock('@perawallet/wallet-core-accounts', () => ({ useAllAccounts: () => [], prefetchLedgerAccountPreview: mockPrefetch, useLedgerAccountPreview: vi.fn(), useLedgerRekeyedScan: mockRekeyedScan, + AccountTypes: { + algo25: 'algo25', + hdWallet: 'hdWallet', + hardware: 'hardware', + multisig: 'multisig', + watch: 'watch', + }, + AccountLogicalTypes: { + HdKey: 'HdKey', + Algo25: 'Algo25', + LedgerBle: 'LedgerBle', + Multisig: 'Multisig', + NoAuth: 'NoAuth', + Rekeyed: 'Rekeyed', + RekeyedAuth: 'RekeyedAuth', + }, + useAccountLogicalType: vi.fn().mockReturnValue(null), + useRekeyTransition: vi.fn().mockReturnValue(null), })) vi.mock('@modules/bottom-sheet', () => ({ From 93d3ee989159aece110072c7d15f0206ff038b8a Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Tue, 19 May 2026 11:34:37 +0100 Subject: [PATCH 32/39] fix(ledger): show account icon via logicalTypeOverride for preview accounts; import cleanup --- .../LedgerAccountInfoContent.tsx | 4 ++- .../useLedgerAccountInfoContent.spec.ts | 28 ++++++++++++++++--- .../useLedgerAccountInfoContent.ts | 13 +++++++-- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoContent.tsx b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoContent.tsx index 13f9fd887..f70c291f0 100644 --- a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoContent.tsx +++ b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoContent.tsx @@ -12,6 +12,7 @@ import { useCallback } from 'react' import { ActivityIndicator } from 'react-native' +import { ALGO_ASSET, ALGO_ASSET_ID } from '@perawallet/wallet-core-assets' import { PWView, PWText, @@ -26,7 +27,6 @@ import { useLanguage } from '@hooks/useLanguage' import { useBottomSheetResult } from '@modules/bottom-sheet' import { AccountDisplay } from '@modules/accounts/components/AccountDisplay' import { AccountAssetItemView } from '@modules/assets/components/AssetItem' -import { ALGO_ASSET, ALGO_ASSET_ID } from '@perawallet/wallet-core-assets' import { useLedgerAccountInfoContent, type LedgerInfoListItem, @@ -75,6 +75,7 @@ export const LedgerAccountInfoContent = ({ account={item.account} showChevron={false} showAccountType + iconProps={{ logicalTypeOverride: item.logicalTypeOverride }} /> ) } diff --git a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/__tests__/useLedgerAccountInfoContent.spec.ts b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/__tests__/useLedgerAccountInfoContent.spec.ts index 4bad437f2..32ecfccc9 100644 --- a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/__tests__/useLedgerAccountInfoContent.spec.ts +++ b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/__tests__/useLedgerAccountInfoContent.spec.ts @@ -13,7 +13,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { renderHook } from '@testing-library/react' import { Decimal } from 'decimal.js' -import { AccountTypes } from '@perawallet/wallet-core-accounts' +import { AccountTypes, AccountLogicalTypes } from '@perawallet/wallet-core-accounts' import { useLedgerAccountInfoContent } from '../useLedgerAccountInfoContent' const mocks = vi.hoisted(() => ({ useLedgerAccountPreview: vi.fn() })) @@ -27,6 +27,15 @@ vi.mock('@perawallet/wallet-core-accounts', () => ({ multisig: 'multisig', watch: 'watch', }, + AccountLogicalTypes: { + Algo25: 'Algo25', + HdKey: 'HdKey', + LedgerBle: 'LedgerBle', + Multisig: 'Multisig', + Rekeyed: 'Rekeyed', + RekeyedAuth: 'RekeyedAuth', + NoAuth: 'NoAuth', + }, })) vi.mock('@hooks/useLanguage', () => ({ useLanguage: () => ({ t: (k: string) => k }), @@ -133,6 +142,7 @@ describe('useLedgerAccountInfoContent', () => { }) if (rekeyAddressItems[0].kind === 'rekeyAddress') { expect(rekeyAddressItems[0].account.address).toBe('AUTH') + expect(rekeyAddressItems[0].logicalTypeOverride).toBe(AccountLogicalTypes.LedgerBle) } }) @@ -154,10 +164,18 @@ describe('useLedgerAccountInfoContent', () => { useLedgerAccountInfoContent('ADDR', 1), ) - const rekeyAddresses = result.current.items - .filter(i => i.kind === 'rekeyAddress') - .map(i => (i.kind === 'rekeyAddress' ? i.account.address : '')) + const rekeyAddressItems = result.current.items.filter( + i => i.kind === 'rekeyAddress', + ) + const rekeyAddresses = rekeyAddressItems.map( + i => (i.kind === 'rekeyAddress' ? i.account.address : ''), + ) expect(rekeyAddresses).toEqual(['R1', 'R2']) + rekeyAddressItems.forEach(i => { + if (i.kind === 'rekeyAddress') { + expect(i.logicalTypeOverride).toBe(AccountLogicalTypes.RekeyedAuth) + } + }) }) it('uses the title override when provided, ignoring the index', () => { @@ -229,6 +247,7 @@ describe('useLedgerAccountInfoContent', () => { expect(acct.account.hardwareDetails.accountIndex).toBe(2) expect(acct.account.hardwareDetails.manufacturer).toBe('ledger') } + expect(acct.logicalTypeOverride).toBe(AccountLogicalTypes.LedgerBle) } }) @@ -255,6 +274,7 @@ describe('useLedgerAccountInfoContent', () => { expect(acct.account.type).toBe(AccountTypes.watch) expect(acct.account.address).toBe('WATCH_ADDR') expect(acct.account.rekeyAddress).toBe('AUTH_ADDR') + expect(acct.logicalTypeOverride).toBe(AccountLogicalTypes.RekeyedAuth) } }) diff --git a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/useLedgerAccountInfoContent.ts b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/useLedgerAccountInfoContent.ts index 09ffbc0a0..f06e65d5e 100644 --- a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/useLedgerAccountInfoContent.ts +++ b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/useLedgerAccountInfoContent.ts @@ -14,12 +14,14 @@ import { useMemo } from 'react' import { Decimal } from 'decimal.js' import { useLedgerAccountPreview, + AccountLogicalTypes, AccountTypes, + type AccountLogicalType, + type AssetWithAccountBalance, type WalletAccount, type HardwareWalletAccount, type WatchAccount, } from '@perawallet/wallet-core-accounts' -import type { AssetWithAccountBalance } from '@perawallet/wallet-core-accounts' import { useLanguage } from '@hooks/useLanguage' export type LedgerInfoListItem = @@ -30,6 +32,7 @@ export type LedgerInfoListItem = account: WalletAccount algoBalance: Decimal algoUsdPrice: Decimal + logicalTypeOverride: AccountLogicalType } | { kind: 'asset' @@ -37,7 +40,7 @@ export type LedgerInfoListItem = accountBalance: AssetWithAccountBalance usdPrice: Decimal } - | { kind: 'rekeyAddress'; key: string; account: WalletAccount } + | { kind: 'rekeyAddress'; key: string; account: WalletAccount; logicalTypeOverride: AccountLogicalType } type UseLedgerAccountInfoContentResult = { title: string @@ -99,6 +102,10 @@ export const useLedgerAccountInfoContent = ( account: synthAccount, algoBalance: preview.algoBalance, algoUsdPrice, + logicalTypeOverride: + preview.rekey.kind === 'rekeyedTo' + ? AccountLogicalTypes.RekeyedAuth + : AccountLogicalTypes.LedgerBle, }, { kind: 'sectionHeader', @@ -144,6 +151,7 @@ export const useLedgerAccountInfoContent = ( kind: 'rekeyAddress', key: `rekey-${preview.rekey.authAddress}`, account: authSynthAccount, + logicalTypeOverride: AccountLogicalTypes.LedgerBle, }, ) } else if (preview.rekey.kind === 'canSignFor') { @@ -162,6 +170,7 @@ export const useLedgerAccountInfoContent = ( kind: 'rekeyAddress', key: `rekey-${addr}`, account: watchSynth, + logicalTypeOverride: AccountLogicalTypes.RekeyedAuth, }) }) } From 80b305fcc4633da8fd93e076187c88f40bc94dec Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Tue, 19 May 2026 11:41:14 +0100 Subject: [PATCH 33/39] fix(ledger): always render checkbox on account rows (checked+disabled when imported) --- .../ledger-imported-account-row.test.tsx | 123 ++++++++++++++++++ .../LedgerAccountSelectionRow.tsx | 15 +-- 2 files changed, 130 insertions(+), 8 deletions(-) create mode 100644 apps/mobile/src/__integration__/ledger-imported-account-row.test.tsx diff --git a/apps/mobile/src/__integration__/ledger-imported-account-row.test.tsx b/apps/mobile/src/__integration__/ledger-imported-account-row.test.tsx new file mode 100644 index 000000000..a854208f9 --- /dev/null +++ b/apps/mobile/src/__integration__/ledger-imported-account-row.test.tsx @@ -0,0 +1,123 @@ +/* + 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 { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, +} from 'vitest' +import { screen, waitFor } from '@testing-library/react' + +import { server } from '@test-utils/msw-server' +import { renderWithNavigation } from '@test-utils/renderWithNavigation' +import { resetTestKeystore } from '@test-utils/algorand-keystore-test' +import { + resetTestDatabase, + seedAlgoAsset, + setupTestDatabase, + teardownTestDatabase, +} from '@test-utils/database-setup' +import { AccountTypes, useAccountsStore } from '@perawallet/wallet-core-accounts' +import type { HardwareWalletAccount } from '@perawallet/wallet-core-accounts' +import { + mockAlgodAccountInformation, + mockAlgodStatus, + mockIndexerSearchForAccounts, +} from '@perawallet/wallet-core-blockchain/test-handlers' +import { LedgerSelectAccountsScreen } from '@modules/ledger/screens/LedgerSelectAccountsScreen' + +import { HD_TEST_ADDRESS } from './__fixtures__/onboarding' + +const SLOW_TEST_TIMEOUT_MS = 30000 + +const LEDGER_ADDRESS = HD_TEST_ADDRESS + +describe('Flow: Ledger imported account row checkbox', () => { + beforeAll(async () => { + server.listen({ onUnhandledRequest: 'warn' }) + await setupTestDatabase() + }) + afterEach(() => server.resetHandlers()) + afterAll(async () => { + server.close() + await teardownTestDatabase() + }) + + beforeEach(async () => { + await resetTestDatabase() + await seedAlgoAsset('mainnet') + resetTestKeystore() + useAccountsStore.getState().setAccounts([]) + + useAccountsStore.getState().setAccounts([ + { + type: AccountTypes.hardware, + address: LEDGER_ADDRESS, + hardwareDetails: { + manufacturer: 'ledger', + deviceId: 'd', + deviceName: 'Ledger Nano X', + accountIndex: 0, + transportType: 'ble', + }, + } satisfies HardwareWalletAccount, + ]) + + server.use( + mockAlgodAccountInformation({ + address: LEDGER_ADDRESS, + response: { amount: 408_200_000, 'min-balance': 100_000 }, + }), + mockAlgodStatus({ response: { 'last-round': 100 } }), + mockIndexerSearchForAccounts(), + ) + }) + + it( + 'Given an already-imported Ledger account discovered on the select-accounts screen, the row renders its checkbox (checked and disabled)', + async () => { + renderWithNavigation( + LedgerSelectAccountsScreen, + 'LedgerSelectAccounts', + { + initialParams: { + deviceId: 'test-device-id', + deviceName: 'Ledger Nano X', + transportType: 'ble', + accounts: [ + { + address: LEDGER_ADDRESS, + publicKey: new Uint8Array([1]), + accountIndex: 0, + }, + ], + }, + }, + ) + + await waitFor( + () => + expect( + screen.getByTestId( + `ledger_select_row_${LEDGER_ADDRESS}-checkbox`, + ), + ).toBeTruthy(), + { timeout: 10000 }, + ) + }, + SLOW_TEST_TIMEOUT_MS, + ) +}) diff --git a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/LedgerAccountSelectionRow/LedgerAccountSelectionRow.tsx b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/LedgerAccountSelectionRow/LedgerAccountSelectionRow.tsx index 00e0fac3a..0285f659d 100644 --- a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/LedgerAccountSelectionRow/LedgerAccountSelectionRow.tsx +++ b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/LedgerAccountSelectionRow/LedgerAccountSelectionRow.tsx @@ -66,14 +66,13 @@ export const LedgerAccountSelectionRow = ({ disabled={isImported} testID={testID} > - {!isImported && ( - - )} + Date: Tue, 19 May 2026 11:45:16 +0100 Subject: [PATCH 34/39] style(ledger): copyright-header fixups for UI fixes --- .../ledger-imported-account-row.test.tsx | 5 ++++- .../LedgerAccountInfoContent.tsx | 9 ++++++-- .../useLedgerAccountInfoContent.spec.ts | 21 +++++++++++++------ .../useLedgerAccountInfoContent.ts | 7 ++++++- 4 files changed, 32 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/__integration__/ledger-imported-account-row.test.tsx b/apps/mobile/src/__integration__/ledger-imported-account-row.test.tsx index a854208f9..89f771d00 100644 --- a/apps/mobile/src/__integration__/ledger-imported-account-row.test.tsx +++ b/apps/mobile/src/__integration__/ledger-imported-account-row.test.tsx @@ -30,7 +30,10 @@ import { setupTestDatabase, teardownTestDatabase, } from '@test-utils/database-setup' -import { AccountTypes, useAccountsStore } from '@perawallet/wallet-core-accounts' +import { + AccountTypes, + useAccountsStore, +} from '@perawallet/wallet-core-accounts' import type { HardwareWalletAccount } from '@perawallet/wallet-core-accounts' import { mockAlgodAccountInformation, diff --git a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoContent.tsx b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoContent.tsx index f70c291f0..61d53fc7e 100644 --- a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoContent.tsx +++ b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoContent.tsx @@ -75,7 +75,10 @@ export const LedgerAccountInfoContent = ({ account={item.account} showChevron={false} showAccountType - iconProps={{ logicalTypeOverride: item.logicalTypeOverride }} + iconProps={{ + logicalTypeOverride: + item.logicalTypeOverride, + }} /> ) } diff --git a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/__tests__/useLedgerAccountInfoContent.spec.ts b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/__tests__/useLedgerAccountInfoContent.spec.ts index 32ecfccc9..c67fb0b0b 100644 --- a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/__tests__/useLedgerAccountInfoContent.spec.ts +++ b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/__tests__/useLedgerAccountInfoContent.spec.ts @@ -13,7 +13,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { renderHook } from '@testing-library/react' import { Decimal } from 'decimal.js' -import { AccountTypes, AccountLogicalTypes } from '@perawallet/wallet-core-accounts' +import { + AccountTypes, + AccountLogicalTypes, +} from '@perawallet/wallet-core-accounts' import { useLedgerAccountInfoContent } from '../useLedgerAccountInfoContent' const mocks = vi.hoisted(() => ({ useLedgerAccountPreview: vi.fn() })) @@ -142,7 +145,9 @@ describe('useLedgerAccountInfoContent', () => { }) if (rekeyAddressItems[0].kind === 'rekeyAddress') { expect(rekeyAddressItems[0].account.address).toBe('AUTH') - expect(rekeyAddressItems[0].logicalTypeOverride).toBe(AccountLogicalTypes.LedgerBle) + expect(rekeyAddressItems[0].logicalTypeOverride).toBe( + AccountLogicalTypes.LedgerBle, + ) } }) @@ -167,13 +172,15 @@ describe('useLedgerAccountInfoContent', () => { const rekeyAddressItems = result.current.items.filter( i => i.kind === 'rekeyAddress', ) - const rekeyAddresses = rekeyAddressItems.map( - i => (i.kind === 'rekeyAddress' ? i.account.address : ''), + const rekeyAddresses = rekeyAddressItems.map(i => + i.kind === 'rekeyAddress' ? i.account.address : '', ) expect(rekeyAddresses).toEqual(['R1', 'R2']) rekeyAddressItems.forEach(i => { if (i.kind === 'rekeyAddress') { - expect(i.logicalTypeOverride).toBe(AccountLogicalTypes.RekeyedAuth) + expect(i.logicalTypeOverride).toBe( + AccountLogicalTypes.RekeyedAuth, + ) } }) }) @@ -274,7 +281,9 @@ describe('useLedgerAccountInfoContent', () => { expect(acct.account.type).toBe(AccountTypes.watch) expect(acct.account.address).toBe('WATCH_ADDR') expect(acct.account.rekeyAddress).toBe('AUTH_ADDR') - expect(acct.logicalTypeOverride).toBe(AccountLogicalTypes.RekeyedAuth) + expect(acct.logicalTypeOverride).toBe( + AccountLogicalTypes.RekeyedAuth, + ) } }) diff --git a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/useLedgerAccountInfoContent.ts b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/useLedgerAccountInfoContent.ts index f06e65d5e..c76e67401 100644 --- a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/useLedgerAccountInfoContent.ts +++ b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/useLedgerAccountInfoContent.ts @@ -40,7 +40,12 @@ export type LedgerInfoListItem = accountBalance: AssetWithAccountBalance usdPrice: Decimal } - | { kind: 'rekeyAddress'; key: string; account: WalletAccount; logicalTypeOverride: AccountLogicalType } + | { + kind: 'rekeyAddress' + key: string + account: WalletAccount + logicalTypeOverride: AccountLogicalType + } type UseLedgerAccountInfoContentResult = { title: string From 1afab311966c2c7c6fb78d75408106a831436e7a Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Tue, 19 May 2026 11:53:03 +0100 Subject: [PATCH 35/39] fix(ledger): remove copy-address from the accounts-found row --- .../LedgerAccountSelectionRow.tsx | 31 ++++--------------- .../LedgerAccountSelectionRow/styles.ts | 6 ---- 2 files changed, 6 insertions(+), 31 deletions(-) diff --git a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/LedgerAccountSelectionRow/LedgerAccountSelectionRow.tsx b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/LedgerAccountSelectionRow/LedgerAccountSelectionRow.tsx index 0285f659d..a63e8480e 100644 --- a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/LedgerAccountSelectionRow/LedgerAccountSelectionRow.tsx +++ b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/LedgerAccountSelectionRow/LedgerAccountSelectionRow.tsx @@ -17,11 +17,9 @@ import { PWTouchableOpacity, PWCheckbox, PWChip, - PWIcon, PWTouchableIcon, } from '@components/core' import { truncateAlgorandAddress } from '@perawallet/wallet-core-shared' -import { useClipboard } from '@hooks/useClipboard' import { useLanguage } from '@hooks/useLanguage' import LightLedgerAccountIcon from '@assets/icons/accounts/light/ledger-account.svg' import { useStyles } from './styles' @@ -49,11 +47,6 @@ export const LedgerAccountSelectionRow = ({ }: LedgerAccountSelectionRowProps) => { const styles = useStyles({ isImported }) const { t } = useLanguage() - const { copyToClipboard } = useClipboard() - - const handleCopyAddress = useCallback(() => { - void copyToClipboard(address) - }, [copyToClipboard, address]) const handleInfoPress = useCallback(() => { onInfoPress(address, accountIndex) @@ -80,25 +73,13 @@ export const LedgerAccountSelectionRow = ({ /> - - - {truncateAlgorandAddress(address)} - - - + {truncateAlgorandAddress(address)} + {variant === 'rekeyed' && ( ({ flex: 1, gap: theme.spacing.xxs, }, - addressTouchable: { - flexDirection: 'row', - alignItems: 'center', - gap: theme.spacing.xxs, - alignSelf: 'flex-start', - }, title: { color: theme.colors.textMain, }, From f33bfc273219ef6490e3e25f93507e25e06cd1cf Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Tue, 19 May 2026 15:00:39 +0100 Subject: [PATCH 36/39] fix(ledger): address code-review findings on the account info sheet - accounts: give useRekeyedAddressesQuery an explicit return type instead of leaking UseQueryResult; update consumer + specs - ledger: dedupe preview prefetch by network+address and cover scanned rekeyed addresses (no full-list re-prefetch on Find another) - ledger: drop dead `accounts` from useLedgerSelectAccountsScreen result; assert via the canonical selectableAccounts surface - ledger: compute real algoValue for info-sheet asset rows - accounts: replace anonymous authAddr cast in mappers with a named, documented type; remove the test `as any` it enabled - accounts: correct the useLedgerRekeyedScan caching JSDoc - test: rename 4 hook specs *.test.ts -> *.spec.ts; give the imported-account-row integration test a real assertion --- .../ledger-imported-account-row.test.tsx | 20 +++-- .../useLedgerAccountInfoContent.spec.ts | 29 ++++++ .../useLedgerAccountInfoContent.ts | 9 +- .../useLedgerSelectAccountsScreen.spec.ts | 88 ++++++++++++++++--- .../useLedgerSelectAccountsScreen.tsx | 34 ++++--- .../src/hooks/__tests__/mappers.spec.ts | 6 +- ...s => prefetchLedgerAccountPreview.spec.ts} | 0 ...est.ts => useLedgerAccountPreview.spec.ts} | 8 +- ...n.test.ts => useLedgerRekeyedScan.spec.ts} | 0 ...st.ts => useRekeyedAddressesQuery.spec.ts} | 12 ++- packages/accounts/src/hooks/mappers.ts | 14 ++- .../src/hooks/useLedgerAccountPreview.ts | 8 +- .../src/hooks/useLedgerRekeyedScan.ts | 9 +- .../src/hooks/useRekeyedAddressesQuery.ts | 23 ++++- 14 files changed, 210 insertions(+), 50 deletions(-) rename packages/accounts/src/hooks/__tests__/{prefetchLedgerAccountPreview.test.ts => prefetchLedgerAccountPreview.spec.ts} (100%) rename packages/accounts/src/hooks/__tests__/{useLedgerAccountPreview.test.ts => useLedgerAccountPreview.spec.ts} (98%) rename packages/accounts/src/hooks/__tests__/{useLedgerRekeyedScan.test.ts => useLedgerRekeyedScan.spec.ts} (100%) rename packages/accounts/src/hooks/__tests__/{useRekeyedAddressesQuery.test.ts => useRekeyedAddressesQuery.spec.ts} (88%) diff --git a/apps/mobile/src/__integration__/ledger-imported-account-row.test.tsx b/apps/mobile/src/__integration__/ledger-imported-account-row.test.tsx index 89f771d00..38ba194f9 100644 --- a/apps/mobile/src/__integration__/ledger-imported-account-row.test.tsx +++ b/apps/mobile/src/__integration__/ledger-imported-account-row.test.tsx @@ -111,15 +111,23 @@ describe('Flow: Ledger imported account row checkbox', () => { }, ) - await waitFor( + const checkbox = await waitFor( () => - expect( - screen.getByTestId( - `ledger_select_row_${LEDGER_ADDRESS}-checkbox`, - ), - ).toBeTruthy(), + screen.getByTestId( + `ledger_select_row_${LEDGER_ADDRESS}-checkbox`, + ), { timeout: 10000 }, ) + + // The "already imported" chip renders under the same `isImported` + // flag that forces the checkbox to checked + disabled in + // LedgerAccountSelectionRow, so its presence is the faithful + // assertion of this scenario. (Integration tests run without + // i18n, so the chip renders the raw key.) + expect(checkbox).not.toBeNull() + expect( + screen.queryByText('ledger.select_accounts.already_imported'), + ).not.toBeNull() }, SLOW_TEST_TIMEOUT_MS, ) diff --git a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/__tests__/useLedgerAccountInfoContent.spec.ts b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/__tests__/useLedgerAccountInfoContent.spec.ts index c67fb0b0b..b7c73f046 100644 --- a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/__tests__/useLedgerAccountInfoContent.spec.ts +++ b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/__tests__/useLedgerAccountInfoContent.spec.ts @@ -314,11 +314,40 @@ describe('useLedgerAccountInfoContent', () => { expect(assetItems[0].accountBalance.amount.toString()).toBe('5') expect(assetItems[0].usdPrice).toBeInstanceOf(Decimal) expect(assetItems[0].usdPrice.toString()).toBe('2') + // ALGO holding: its ALGO value is the amount itself (5 * 2 / 2). + expect(assetItems[0].accountBalance.algoValue.toString()).toBe('5') } if (assetItems[1].kind === 'asset') { expect(assetItems[1].accountBalance.assetId).toBe('12345') expect(assetItems[1].usdPrice.toString()).toBe('1') + // USDC holding value in ALGO = amount * usdPrice / algoUsdPrice + // = 100 * 1 / 2 = 50. + expect(assetItems[1].accountBalance.algoValue.toString()).toBe('50') + } + }) + + it('falls back to a zero algoValue when the ALGO USD price is unknown', () => { + mocks.useLedgerAccountPreview.mockReturnValue({ + preview: { + address: 'ADDR', + algoBalance: new Decimal(5), + totalFiatValue: new Decimal(0), + assets: [{ ...baseAsset, usdPrice: new Decimal(0) }, asaAsset], + rekey: { kind: 'none' }, + }, + isLoading: false, + isError: false, + refetch: vi.fn(), + }) + + const { result } = renderHook(() => + useLedgerAccountInfoContent('ADDR', 0), + ) + + const assetItems = result.current.items.filter(i => i.kind === 'asset') + if (assetItems[1].kind === 'asset') { + expect(assetItems[1].accountBalance.algoValue.toString()).toBe('0') } }) diff --git a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/useLedgerAccountInfoContent.ts b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/useLedgerAccountInfoContent.ts index c76e67401..f16f3c7ed 100644 --- a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/useLedgerAccountInfoContent.ts +++ b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/useLedgerAccountInfoContent.ts @@ -124,7 +124,14 @@ export const useLedgerAccountInfoContent = ( accountBalance: { assetId: asset.assetId, amount: asset.amount, - algoValue: new Decimal(0), + // Holding value in ALGOs (display units): + // amount * usdPrice / algoUsdPrice. Falls back to 0 + // when the ALGO USD price is unknown (avoids /0). + algoValue: algoUsdPrice.isZero() + ? new Decimal(0) + : asset.amount + .times(asset.usdPrice) + .div(algoUsdPrice), } satisfies AssetWithAccountBalance, usdPrice: asset.usdPrice, }), 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 99e708236..45642c22e 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 @@ -149,6 +149,13 @@ const buildAccount = ( accountIndex, }) +// The hook no longer exposes the raw derived list; `selectableAccounts` is the +// canonical surface. This narrows it back to the derived accounts for assertions. +const derivedAccounts = ( + r: ReturnType, +): HardwareWalletDerivedAccount[] => + r.selectableAccounts.flatMap(s => (s.kind === 'derived' ? [s.account] : [])) + describe('useLedgerSelectAccountsScreen', () => { beforeEach(() => { mockNavigate.mockReset() @@ -167,12 +174,13 @@ describe('useLedgerSelectAccountsScreen', () => { mockRekeyedScan.mockReturnValue({ rekeyed: [], isScanning: false }) }) - it('exposes the route accounts unchanged on initial render', () => { + it('reflects the route accounts as derived selectables 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) + const derived = derivedAccounts(result.current) + expect(derived).toHaveLength(2) + expect(derived[0].accountIndex).toBe(0) + expect(derived[1].accountIndex).toBe(1) expect(result.current.isFetchingMore).toBe(false) }) @@ -189,8 +197,9 @@ describe('useLedgerSelectAccountsScreen', () => { 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({ + const derived = derivedAccounts(result.current) + expect(derived).toHaveLength(3) + expect(derived[2]).toEqual({ address: 'CCC333', publicKey: new Uint8Array([2]), accountIndex: 2, @@ -215,8 +224,9 @@ describe('useLedgerSelectAccountsScreen', () => { 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) + const derived = derivedAccounts(result.current) + expect(derived).toHaveLength(4) + expect(derived[3].accountIndex).toBe(3) }) it('is a no-op while a fetch is in flight', async () => { @@ -266,7 +276,7 @@ describe('useLedgerSelectAccountsScreen', () => { }) expect(mockErrorToast).toHaveBeenCalledTimes(1) - expect(result.current.accounts).toHaveLength(2) + expect(derivedAccounts(result.current)).toHaveLength(2) expect(result.current.isFetchingMore).toBe(false) }) @@ -280,7 +290,7 @@ describe('useLedgerSelectAccountsScreen', () => { }) expect(mockErrorToast).toHaveBeenCalledTimes(1) - expect(result.current.accounts).toHaveLength(2) + expect(derivedAccounts(result.current)).toHaveLength(2) expect(result.current.isFetchingMore).toBe(false) }) @@ -391,6 +401,64 @@ describe('useLedgerSelectAccountsScreen', () => { }) }) + it('prefetches discovered rekeyed addresses', async () => { + mockRekeyedScan.mockReturnValue({ + rekeyed: [ + { + kind: 'rekeyed', + address: 'REKEYED_A', + authAccount: { + address: 'AAA111', + publicKey: new Uint8Array([1]), + accountIndex: 0, + }, + }, + ], + isScanning: false, + }) + + renderHook(() => useLedgerSelectAccountsScreen()) + + await waitFor(() => { + expect(mockPrefetch).toHaveBeenCalledWith( + mockQueryClient, + expect.anything(), + 'REKEYED_A', + 'mainnet', + ) + }) + }) + + it('does not re-prefetch already-prefetched accounts when the list grows', async () => { + mockGetAddress.mockResolvedValueOnce(buildAccount(2, 'CCC333')) + const { result } = renderHook(() => useLedgerSelectAccountsScreen()) + + await waitFor(() => { + expect(mockPrefetch).toHaveBeenCalledWith( + mockQueryClient, + expect.anything(), + 'AAA111', + 'mainnet', + ) + }) + + await act(async () => { + await result.current.handleFindAnother() + }) + + await waitFor(() => { + expect(mockPrefetch).toHaveBeenCalledWith( + mockQueryClient, + expect.anything(), + 'CCC333', + 'mainnet', + ) + }) + + const aaaCalls = mockPrefetch.mock.calls.filter(c => c[2] === 'AAA111') + expect(aaaCalls).toHaveLength(1) + }) + it('opens the info bottom sheet with address and accountIndex', () => { const { result } = renderHook(() => useLedgerSelectAccountsScreen()) diff --git a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.tsx b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.tsx index dee1d68c4..0290e1542 100644 --- a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.tsx +++ b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.tsx @@ -45,7 +45,6 @@ type LedgerSelectAccountsRouteProp = RouteProp< > type UseLedgerSelectAccountsScreenResult = { - accounts: LedgerAccount[] selectableAccounts: LedgerSelectableAccount[] isScanning: boolean selectedAddresses: Set @@ -92,6 +91,9 @@ export const useLedgerSelectAccountsScreen = const inFlightRef = useRef(false) const isMountedRef = useRef(true) const accountsRef = useRef(routeAccounts) + // Network-scoped set of addresses already warmed, so growing the + // list (Find another / rekeyed scan) only prefetches new addresses. + const prefetchedRef = useRef>(new Set()) useEffect(() => { isMountedRef.current = true @@ -111,17 +113,6 @@ export const useLedgerSelectAccountsScreen = } }, []) - useEffect(() => { - accounts.forEach(acc => { - void prefetchLedgerAccountPreview( - queryClient, - algokit, - acc.address, - network, - ) - }) - }, [accounts, queryClient, algokit, network]) - const { rekeyed, isScanning } = useLedgerRekeyedScan(accounts) const selectableAccounts = useMemo( @@ -137,6 +128,24 @@ export const useLedgerSelectAccountsScreen = [accounts, rekeyed], ) + useEffect(() => { + for (const selectable of selectableAccounts) { + const address = + selectable.kind === 'derived' + ? selectable.account.address + : selectable.address + const key = `${network}:${address}` + if (prefetchedRef.current.has(key)) continue + prefetchedRef.current.add(key) + void prefetchLedgerAccountPreview( + queryClient, + algokit, + address, + network, + ) + } + }, [selectableAccounts, queryClient, algokit, network]) + const selectableByAddress = useMemo(() => { const m = new Map() for (const s of selectableAccounts) { @@ -311,7 +320,6 @@ export const useLedgerSelectAccountsScreen = !isFetchingMore && (areAllImported || selectedAddresses.size > 0) return { - accounts, selectableAccounts, isScanning, selectedAddresses, diff --git a/packages/accounts/src/hooks/__tests__/mappers.spec.ts b/packages/accounts/src/hooks/__tests__/mappers.spec.ts index 06115c7b5..a45db4528 100644 --- a/packages/accounts/src/hooks/__tests__/mappers.spec.ts +++ b/packages/accounts/src/hooks/__tests__/mappers.spec.ts @@ -15,7 +15,9 @@ import { mapOnChainAccountInformation } from '../mappers' import type { OnChainAccountInformationResponse } from '../endpoints' const buildResponse = ( - overrides: Partial = {}, + overrides: Partial & { + authAddr?: { toString(): string } + } = {}, // eslint-disable-next-line @typescript-eslint/no-explicit-any ): OnChainAccountInformationResponse => ({ @@ -78,7 +80,7 @@ describe('mapOnChainAccountInformation', () => { const mapped = mapOnChainAccountInformation( buildResponse({ authAddr: { toString: () => 'AUTHADDR' }, - } as any), + }), ) expect(mapped.authAddress).toBe('AUTHADDR') diff --git a/packages/accounts/src/hooks/__tests__/prefetchLedgerAccountPreview.test.ts b/packages/accounts/src/hooks/__tests__/prefetchLedgerAccountPreview.spec.ts similarity index 100% rename from packages/accounts/src/hooks/__tests__/prefetchLedgerAccountPreview.test.ts rename to packages/accounts/src/hooks/__tests__/prefetchLedgerAccountPreview.spec.ts diff --git a/packages/accounts/src/hooks/__tests__/useLedgerAccountPreview.test.ts b/packages/accounts/src/hooks/__tests__/useLedgerAccountPreview.spec.ts similarity index 98% rename from packages/accounts/src/hooks/__tests__/useLedgerAccountPreview.test.ts rename to packages/accounts/src/hooks/__tests__/useLedgerAccountPreview.spec.ts index 58fa5351b..b1de479b7 100644 --- a/packages/accounts/src/hooks/__tests__/useLedgerAccountPreview.test.ts +++ b/packages/accounts/src/hooks/__tests__/useLedgerAccountPreview.spec.ts @@ -58,7 +58,7 @@ beforeEach(() => { isPending: false, }) mocks.useRekeyedAddressesQuery.mockReturnValue({ - data: [], + rekeyedAddresses: [], isLoading: false, isError: false, }) @@ -198,7 +198,7 @@ describe('useLedgerAccountPreview', () => { refetch: vi.fn(), }) mocks.useRekeyedAddressesQuery.mockReturnValue({ - data: ['SOMEONE'], + rekeyedAddresses: ['SOMEONE'], isLoading: false, isError: false, }) @@ -226,7 +226,7 @@ describe('useLedgerAccountPreview', () => { refetch: vi.fn(), }) mocks.useRekeyedAddressesQuery.mockReturnValue({ - data: ['R1', 'R2'], + rekeyedAddresses: ['R1', 'R2'], isLoading: false, isError: false, }) @@ -298,7 +298,7 @@ describe('useLedgerAccountPreview', () => { refetch: vi.fn(), }) mocks.useRekeyedAddressesQuery.mockReturnValue({ - data: undefined, + rekeyedAddresses: undefined, isLoading: false, isError: true, }) diff --git a/packages/accounts/src/hooks/__tests__/useLedgerRekeyedScan.test.ts b/packages/accounts/src/hooks/__tests__/useLedgerRekeyedScan.spec.ts similarity index 100% rename from packages/accounts/src/hooks/__tests__/useLedgerRekeyedScan.test.ts rename to packages/accounts/src/hooks/__tests__/useLedgerRekeyedScan.spec.ts diff --git a/packages/accounts/src/hooks/__tests__/useRekeyedAddressesQuery.test.ts b/packages/accounts/src/hooks/__tests__/useRekeyedAddressesQuery.spec.ts similarity index 88% rename from packages/accounts/src/hooks/__tests__/useRekeyedAddressesQuery.test.ts rename to packages/accounts/src/hooks/__tests__/useRekeyedAddressesQuery.spec.ts index 1768d7354..3f2cb552c 100644 --- a/packages/accounts/src/hooks/__tests__/useRekeyedAddressesQuery.test.ts +++ b/packages/accounts/src/hooks/__tests__/useRekeyedAddressesQuery.spec.ts @@ -61,8 +61,13 @@ describe('useRekeyedAddressesQuery', () => { wrapper: createWrapper(), }) - await waitFor(() => expect(result.current.isSuccess).toBe(true)) - expect(result.current.data).toEqual(['REKEYED1', 'REKEYED2']) + await waitFor(() => + expect(result.current.rekeyedAddresses).toEqual([ + 'REKEYED1', + 'REKEYED2', + ]), + ) + expect(result.current.isError).toBe(false) expect(mocks.fetchRekeyedAddresses).toHaveBeenCalledWith('ADDR') }) @@ -71,7 +76,8 @@ describe('useRekeyedAddressesQuery', () => { wrapper: createWrapper(), }) - expect(result.current.fetchStatus).toBe('idle') + expect(result.current.rekeyedAddresses).toBeUndefined() + expect(result.current.isLoading).toBe(false) expect(mocks.fetchRekeyedAddresses).not.toHaveBeenCalled() }) }) diff --git a/packages/accounts/src/hooks/mappers.ts b/packages/accounts/src/hooks/mappers.ts index c9ebd6eb7..b3429eead 100644 --- a/packages/accounts/src/hooks/mappers.ts +++ b/packages/accounts/src/hooks/mappers.ts @@ -13,11 +13,21 @@ import type { AccountInformation } from '@perawallet/wallet-core-blockchain' import type { OnChainAccountInformationResponse } from './endpoints' +/** + * AlgoKit's algod account response carries `authAddr` (the rekey/auth + * address), but the `OnChainAccountInformationResponse` alias inferred from + * the algod client method does not surface it. This narrows that single field + * access without widening (or mis-stating) the whole response shape — and + * documents why the assertion is needed so it stays greppable. + */ +type WithAuthAddr = { authAddr?: { toString(): string } } + export const mapOnChainAccountInformation = ( response: OnChainAccountInformationResponse, ): AccountInformation => { - const authAddr = (response as { authAddr?: { toString(): string } }) - .authAddr + const authAddr = ( + response as OnChainAccountInformationResponse & WithAuthAddr + ).authAddr return { address: response.address, amount: response.amount, diff --git a/packages/accounts/src/hooks/useLedgerAccountPreview.ts b/packages/accounts/src/hooks/useLedgerAccountPreview.ts index c0b94cc5f..0a0267116 100644 --- a/packages/accounts/src/hooks/useLedgerAccountPreview.ts +++ b/packages/accounts/src/hooks/useLedgerAccountPreview.ts @@ -102,10 +102,10 @@ export const useLedgerAccountPreview = ( rekey = { kind: 'rekeyedTo', authAddress } } else if ( !rekeyed.isError && - rekeyed.data && - rekeyed.data.length > 0 + rekeyed.rekeyedAddresses && + rekeyed.rekeyedAddresses.length > 0 ) { - rekey = { kind: 'canSignFor', addresses: rekeyed.data } + rekey = { kind: 'canSignFor', addresses: rekeyed.rekeyedAddresses } } return { @@ -120,7 +120,7 @@ export const useLedgerAccountPreview = ( onChain.data, assets, prices, - rekeyed.data, + rekeyed.rekeyedAddresses, rekeyed.isError, usdToPreferred, ]) diff --git a/packages/accounts/src/hooks/useLedgerRekeyedScan.ts b/packages/accounts/src/hooks/useLedgerRekeyedScan.ts index 23e8d962e..d8f3d4531 100644 --- a/packages/accounts/src/hooks/useLedgerRekeyedScan.ts +++ b/packages/accounts/src/hooks/useLedgerRekeyedScan.ts @@ -26,9 +26,12 @@ type UseLedgerRekeyedScanResult = { /** * For each discovered Ledger (derived) account, scans the indexer for accounts - * rekeyed to it (reusing the cached `rekeyed-addresses` query warmed by - * `prefetchLedgerAccountPreview`) and returns them as `rekeyed` selectables. - * Best-effort: a failed/empty scan yields no rows for that address. + * rekeyed to it and returns them as `rekeyed` selectables. + * + * Shares the `rekeyed-addresses` query key with `prefetchLedgerAccountPreview`, + * so a prior prefetch supplies an immediate (non-empty) first value; with + * `staleTime: 0` the query still refetches in the background to keep rekey + * data fresh. Best-effort: a failed/empty scan yields no rows for that address. */ export const useLedgerRekeyedScan = ( derivedAccounts: HardwareWalletDerivedAccount[], diff --git a/packages/accounts/src/hooks/useRekeyedAddressesQuery.ts b/packages/accounts/src/hooks/useRekeyedAddressesQuery.ts index 698d7ec50..4a5492def 100644 --- a/packages/accounts/src/hooks/useRekeyedAddressesQuery.ts +++ b/packages/accounts/src/hooks/useRekeyedAddressesQuery.ts @@ -15,13 +15,32 @@ import { useNetwork } from '@perawallet/wallet-core-blockchain' import { fetchRekeyedAddresses } from '../account-discovery' import { getRekeyedAddressesQueryKey } from './querykeys' -export const useRekeyedAddressesQuery = (address: string) => { +type UseRekeyedAddressesQueryResult = { + /** Addresses rekeyed to `address`; `undefined` until the query resolves */ + rekeyedAddresses: string[] | undefined + isLoading: boolean + isError: boolean + refetch: () => void +} + +export const useRekeyedAddressesQuery = ( + address: string, +): UseRekeyedAddressesQueryResult => { const { network } = useNetwork() - return useQuery({ + const query = useQuery({ queryKey: getRekeyedAddressesQueryKey(address, network), queryFn: () => fetchRekeyedAddresses(address), enabled: !!address, staleTime: 0, }) + + return { + rekeyedAddresses: query.data, + isLoading: query.isLoading, + isError: query.isError, + refetch: () => { + void query.refetch() + }, + } } From 2d4ea4b51a363503d5fc83366a9c7dab09a1f4d4 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Wed, 20 May 2026 12:33:48 +0100 Subject: [PATCH 37/39] fix(ledger): bind connecting-sheet Cancel to a stable close handler QA reports the Ledger Connection sheet's Cancel button can fail to close the sheet in some cases on the accounts-found flow. The wiring itself was correct (resolve('cancel') is the close function), but the inline arrow created a fresh onPress reference each render. Extract a named, memoized handleClose so onPress is a stable bound function on PWButton -> PWTouchableOpacity -> TouchableOpacity. --- .../LedgerConnectingContent/LedgerConnectingContent.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/modules/ledger/components/LedgerConnectingContent/LedgerConnectingContent.tsx b/apps/mobile/src/modules/ledger/components/LedgerConnectingContent/LedgerConnectingContent.tsx index 97d67f8fe..d2964a3ef 100644 --- a/apps/mobile/src/modules/ledger/components/LedgerConnectingContent/LedgerConnectingContent.tsx +++ b/apps/mobile/src/modules/ledger/components/LedgerConnectingContent/LedgerConnectingContent.tsx @@ -10,6 +10,7 @@ limitations under the License */ +import { useCallback } from 'react' import { PWView, PWText, PWButton } from '@components/core' import { useLanguage } from '@hooks/useLanguage' import { useBottomSheetResult } from '@modules/bottom-sheet' @@ -21,6 +22,11 @@ export const LedgerConnectingContent = () => { const { t } = useLanguage() const { resolve } = useBottomSheetResult<'cancel'>() + // Named, memoized close handler so the button's onPress is a stable + // reference bound to the sheet's resolve — `resolve('cancel')` is what + // actually dismisses this sheet and signals `cancel` to the screen. + const handleClose = useCallback(() => resolve('cancel'), [resolve]) + return ( @@ -39,7 +45,7 @@ export const LedgerConnectingContent = () => { resolve('cancel')} + onPress={handleClose} style={styles.button} testID='ledger_connecting_cancel_button' /> From 2fa5cf1af97c56fc554a4f0a6cc5996946e13f14 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Wed, 20 May 2026 13:37:23 +0100 Subject: [PATCH 38/39] fix(ledger): address second-pass review findings - i18n: replace hardcoded `Ledger #N` default title with ledger.account_info.default_title and {{index}} interpolation - accounts: thread `network` through fetchRekeyedAddresses so the fetch is network-scoped by construction, matching the query keys and the sibling fetchOnChainAccountInformation(algokit, ...) call - ledger: stabilize useLedgerRekeyedScan memo with a primitive resultsSig dep (useQueries returns a fresh array every render) - accounts: 30s staleTime on the rekeyed-addresses queries so the prefetch warm-up actually pays off across a Ledger import session - ledger: clear prefetchedRef on network switch (each entry is bound to a specific network, the prefix implied that intent) - ledger: document the watch/hardware `id` asymmetry in handleAdd to match the existing rekeyed-watch precedent --- .../ledger-account-info-sheet.test.tsx | 8 +++-- apps/mobile/src/i18n/locales/en.json | 1 + .../useLedgerAccountInfoContent.spec.ts | 19 ++++++++--- .../useLedgerAccountInfoContent.ts | 4 ++- .../useLedgerSelectAccountsScreen.tsx | 7 ++++ .../useLedgerVerifyScreen.ts | 6 ++++ .../src/__tests__/account-discovery.spec.ts | 11 ++++--- packages/accounts/src/account-discovery.ts | 8 ++++- .../prefetchLedgerAccountPreview.spec.ts | 5 ++- .../useRekeyedAddressesQuery.spec.ts | 5 ++- .../src/hooks/prefetchLedgerAccountPreview.ts | 2 +- .../src/hooks/useLedgerRekeyedScan.ts | 32 +++++++++++++++---- .../src/hooks/useRekeyedAddressesQuery.ts | 8 +++-- .../src/hooks/useRescanRekeyedAccounts.ts | 13 ++++++-- 14 files changed, 102 insertions(+), 27 deletions(-) diff --git a/apps/mobile/src/__integration__/ledger-account-info-sheet.test.tsx b/apps/mobile/src/__integration__/ledger-account-info-sheet.test.tsx index 4a40860b1..69b089a14 100644 --- a/apps/mobile/src/__integration__/ledger-account-info-sheet.test.tsx +++ b/apps/mobile/src/__integration__/ledger-account-info-sheet.test.tsx @@ -113,9 +113,13 @@ describe('Flow: Ledger account info sheet', () => { { timeout: 10000 }, ) - // The sheet title should be "Ledger #0" + // The sheet title is the i18n key (integration harness doesn't + // initialise i18n — t() returns the raw key, see comment below). await waitFor( - () => expect(screen.getByText('Ledger #0')).toBeTruthy(), + () => + expect( + screen.getByText('ledger.account_info.default_title'), + ).toBeTruthy(), { timeout: 10000 }, ) diff --git a/apps/mobile/src/i18n/locales/en.json b/apps/mobile/src/i18n/locales/en.json index 9e09f7ac9..9da01e694 100644 --- a/apps/mobile/src/i18n/locales/en.json +++ b/apps/mobile/src/i18n/locales/en.json @@ -2138,6 +2138,7 @@ "account_info": { "account_details": "Account details", "assets": "Assets", + "default_title": "Ledger #{{index}}", "ledger_account_label": "Ledger Account", "can_be_signed_by": "Can be signed by", "can_sign_for": "Can sign for these", diff --git a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/__tests__/useLedgerAccountInfoContent.spec.ts b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/__tests__/useLedgerAccountInfoContent.spec.ts index b7c73f046..da8cf4659 100644 --- a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/__tests__/useLedgerAccountInfoContent.spec.ts +++ b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/__tests__/useLedgerAccountInfoContent.spec.ts @@ -19,7 +19,10 @@ import { } from '@perawallet/wallet-core-accounts' import { useLedgerAccountInfoContent } from '../useLedgerAccountInfoContent' -const mocks = vi.hoisted(() => ({ useLedgerAccountPreview: vi.fn() })) +const mocks = vi.hoisted(() => ({ + useLedgerAccountPreview: vi.fn(), + t: vi.fn((k: string, _opts?: Record) => k), +})) vi.mock('@perawallet/wallet-core-accounts', () => ({ useLedgerAccountPreview: mocks.useLedgerAccountPreview, @@ -41,7 +44,7 @@ vi.mock('@perawallet/wallet-core-accounts', () => ({ }, })) vi.mock('@hooks/useLanguage', () => ({ - useLanguage: () => ({ t: (k: string) => k }), + useLanguage: () => ({ t: mocks.t }), })) const baseAsset = { @@ -85,7 +88,11 @@ describe('useLedgerAccountInfoContent', () => { useLedgerAccountInfoContent('ADDR', 0), ) - expect(result.current.title).toBe('Ledger #0') + expect(result.current.title).toBe('ledger.account_info.default_title') + expect(mocks.t).toHaveBeenCalledWith( + 'ledger.account_info.default_title', + { index: 0 }, + ) expect(result.current.isLoading).toBe(true) expect(result.current.items).toEqual([]) }) @@ -115,7 +122,11 @@ describe('useLedgerAccountInfoContent', () => { 'sectionHeader', 'asset', ]) - expect(result.current.title).toBe('Ledger #3') + expect(result.current.title).toBe('ledger.account_info.default_title') + expect(mocks.t).toHaveBeenCalledWith( + 'ledger.account_info.default_title', + { index: 3 }, + ) }) it('emits a "can be signed by" rekey section when rekeyedTo', () => { diff --git a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/useLedgerAccountInfoContent.ts b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/useLedgerAccountInfoContent.ts index f16f3c7ed..72b13377f 100644 --- a/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/useLedgerAccountInfoContent.ts +++ b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/useLedgerAccountInfoContent.ts @@ -191,7 +191,9 @@ export const useLedgerAccountInfoContent = ( }, [preview, t, accountIndex]) return { - title: titleOverride ?? `Ledger #${accountIndex}`, + title: + titleOverride ?? + t('ledger.account_info.default_title', { index: accountIndex }), items, isLoading, isError, diff --git a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.tsx b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.tsx index 0290e1542..52ef34218 100644 --- a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.tsx +++ b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.tsx @@ -113,6 +113,13 @@ export const useLedgerSelectAccountsScreen = } }, []) + // Each entry in `prefetchedRef` is bound to a specific network — when + // the active network changes, those entries no longer represent warm + // caches, so reset and let the prefetch effect re-warm the list. + useEffect(() => { + prefetchedRef.current = new Set() + }, [network]) + const { rekeyed, isScanning } = useLedgerRekeyedScan(accounts) const selectableAccounts = useMemo( diff --git a/apps/mobile/src/modules/ledger/screens/LedgerVerifyScreen/useLedgerVerifyScreen.ts b/apps/mobile/src/modules/ledger/screens/LedgerVerifyScreen/useLedgerVerifyScreen.ts index 39f1b412a..5cdfe4e09 100644 --- a/apps/mobile/src/modules/ledger/screens/LedgerVerifyScreen/useLedgerVerifyScreen.ts +++ b/apps/mobile/src/modules/ledger/screens/LedgerVerifyScreen/useLedgerVerifyScreen.ts @@ -197,6 +197,12 @@ export const useLedgerVerifyScreen = (): UseLedgerVerifyScreenResult => { !added.has(sel.address) ) { added.add(sel.address) + // Watch entries carry an explicit `id` to match the + // existing rekeyed-watch precedent in account-discovery + // (`fetchRekeyedAddresses` consumers). Hardware accounts + // are deduped by `address` (see `addHardware` above and + // every other hardware-creation site), so they + // deliberately leave `id` unset. batch.push({ id: generateOrderedUniqueId(), type: AccountTypes.watch, diff --git a/packages/accounts/src/__tests__/account-discovery.spec.ts b/packages/accounts/src/__tests__/account-discovery.spec.ts index 5f10c5d0f..eb6e3465c 100644 --- a/packages/accounts/src/__tests__/account-discovery.spec.ts +++ b/packages/accounts/src/__tests__/account-discovery.spec.ts @@ -301,7 +301,10 @@ describe('discoverRekeyedAccounts', () => { }, } as any) - const addresses = await fetchRekeyedAddresses('AUTH_ADDRESS') + const addresses = await fetchRekeyedAddresses( + 'AUTH_ADDRESS', + 'mainnet', + ) expect(addresses).toEqual(['REKEYED_PAGE_1', 'REKEYED_PAGE_2']) expect(mockSearchForAccounts).toHaveBeenCalledTimes(2) @@ -320,8 +323,8 @@ describe('discoverRekeyedAccounts', () => { }, } as any) - await expect(fetchRekeyedAddresses('AUTH_ADDRESS')).rejects.toThrow( - 'indexer unreachable', - ) + await expect( + fetchRekeyedAddresses('AUTH_ADDRESS', 'mainnet'), + ).rejects.toThrow('indexer unreachable') }) }) diff --git a/packages/accounts/src/account-discovery.ts b/packages/accounts/src/account-discovery.ts index 443619170..67193a56c 100644 --- a/packages/accounts/src/account-discovery.ts +++ b/packages/accounts/src/account-discovery.ts @@ -32,6 +32,7 @@ import { generateOrderedUniqueId, fetchAccountFastLookup, logger, + type Network, type Nullable, } from '@perawallet/wallet-core-shared' import { hdDerivedKeyId } from '@perawallet/wallet-core-kms' @@ -302,11 +303,16 @@ async function checkRekeyed( * on-chain account whose auth-addr is `address`. Used to surface accounts * the user could re-import as watch entries after a rekey was performed * outside the wallet. Mirrors Android's `fetchRekeyedAddresses`. + * + * `network` is required so the indexer client matches the network-scoped + * query key callers use (no implicit reliance on the global active-network + * store), keeping fetch and cache key in lockstep across network switches. */ export async function fetchRekeyedAddresses( address: string, + network: Network, ): Promise { - const algorandClient = getAlgorandClient() + const algorandClient = getAlgorandClient(network) const accounts = await checkRekeyed(algorandClient, address) return accounts.map(a => a.address) } diff --git a/packages/accounts/src/hooks/__tests__/prefetchLedgerAccountPreview.spec.ts b/packages/accounts/src/hooks/__tests__/prefetchLedgerAccountPreview.spec.ts index 412a5f381..11b8e9fd2 100644 --- a/packages/accounts/src/hooks/__tests__/prefetchLedgerAccountPreview.spec.ts +++ b/packages/accounts/src/hooks/__tests__/prefetchLedgerAccountPreview.spec.ts @@ -73,7 +73,10 @@ describe('prefetchLedgerAccountPreview', () => { algokit, 'ADDR', ) - expect(mocks.fetchRekeyedAddresses).toHaveBeenCalledWith('ADDR') + expect(mocks.fetchRekeyedAddresses).toHaveBeenCalledWith( + 'ADDR', + 'mainnet', + ) }) it('never rejects when a fetch fails (best-effort)', async () => { diff --git a/packages/accounts/src/hooks/__tests__/useRekeyedAddressesQuery.spec.ts b/packages/accounts/src/hooks/__tests__/useRekeyedAddressesQuery.spec.ts index 3f2cb552c..d77200e83 100644 --- a/packages/accounts/src/hooks/__tests__/useRekeyedAddressesQuery.spec.ts +++ b/packages/accounts/src/hooks/__tests__/useRekeyedAddressesQuery.spec.ts @@ -68,7 +68,10 @@ describe('useRekeyedAddressesQuery', () => { ]), ) expect(result.current.isError).toBe(false) - expect(mocks.fetchRekeyedAddresses).toHaveBeenCalledWith('ADDR') + expect(mocks.fetchRekeyedAddresses).toHaveBeenCalledWith( + 'ADDR', + 'mainnet', + ) }) it('is disabled when address is empty', () => { diff --git a/packages/accounts/src/hooks/prefetchLedgerAccountPreview.ts b/packages/accounts/src/hooks/prefetchLedgerAccountPreview.ts index 8ec0427ca..675039cf3 100644 --- a/packages/accounts/src/hooks/prefetchLedgerAccountPreview.ts +++ b/packages/accounts/src/hooks/prefetchLedgerAccountPreview.ts @@ -40,7 +40,7 @@ export const prefetchLedgerAccountPreview = async ( }), queryClient.prefetchQuery({ queryKey: getRekeyedAddressesQueryKey(address, network), - queryFn: () => fetchRekeyedAddresses(address), + queryFn: () => fetchRekeyedAddresses(address, network), }), ]) } diff --git a/packages/accounts/src/hooks/useLedgerRekeyedScan.ts b/packages/accounts/src/hooks/useLedgerRekeyedScan.ts index d8f3d4531..ae47bed85 100644 --- a/packages/accounts/src/hooks/useLedgerRekeyedScan.ts +++ b/packages/accounts/src/hooks/useLedgerRekeyedScan.ts @@ -28,10 +28,11 @@ type UseLedgerRekeyedScanResult = { * For each discovered Ledger (derived) account, scans the indexer for accounts * rekeyed to it and returns them as `rekeyed` selectables. * - * Shares the `rekeyed-addresses` query key with `prefetchLedgerAccountPreview`, - * so a prior prefetch supplies an immediate (non-empty) first value; with - * `staleTime: 0` the query still refetches in the background to keep rekey - * data fresh. Best-effort: a failed/empty scan yields no rows for that address. + * Shares the `rekeyed-addresses` query key with `prefetchLedgerAccountPreview` + * so a prior prefetch supplies an immediate first value. A small `staleTime` + * lets the prefetch actually pay off across this short-lived import session; + * rescan flows invalidate the cache when fresher data is explicitly required. + * Best-effort: a failed/empty scan yields no rows for that address. */ export const useLedgerRekeyedScan = ( derivedAccounts: HardwareWalletDerivedAccount[], @@ -42,11 +43,25 @@ export const useLedgerRekeyedScan = ( const results = useQueries({ queries: derivedAccounts.map(acc => ({ queryKey: getRekeyedAddressesQueryKey(acc.address, network), - queryFn: () => fetchRekeyedAddresses(acc.address), - staleTime: 0, + queryFn: () => fetchRekeyedAddresses(acc.address, network), + staleTime: 30_000, })), }) + // `useQueries` returns a fresh `results` array on every render even when + // nothing changed, so depending on it directly defeats memoization. This + // primitive signature captures everything the body actually reads, and + // Object.is stays true across renders when content matches — so the + // useMemo below skips recomputation until a query result actually moves. + const resultsSig = results + .map( + (r, i) => + `${derivedAccounts[i]?.address ?? ''}|${ + r.isPending ? 1 : 0 + }|${(r.data ?? []).join(',')}`, + ) + .join('||') + return useMemo(() => { const derivedAddresses = new Set(derivedAccounts.map(a => a.address)) const importedAddresses = new Set(allAccounts.map(a => a.address)) @@ -72,5 +87,8 @@ export const useLedgerRekeyedScan = ( const isScanning = results.some(r => r.isPending) return { rekeyed, isScanning } - }, [results, derivedAccounts, allAccounts]) + // `resultsSig` encodes everything we read from `results`; depending + // on `results` itself would force a re-compute every render. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [resultsSig, derivedAccounts, allAccounts]) } diff --git a/packages/accounts/src/hooks/useRekeyedAddressesQuery.ts b/packages/accounts/src/hooks/useRekeyedAddressesQuery.ts index 4a5492def..1cb0f7e25 100644 --- a/packages/accounts/src/hooks/useRekeyedAddressesQuery.ts +++ b/packages/accounts/src/hooks/useRekeyedAddressesQuery.ts @@ -30,9 +30,13 @@ export const useRekeyedAddressesQuery = ( const query = useQuery({ queryKey: getRekeyedAddressesQueryKey(address, network), - queryFn: () => fetchRekeyedAddresses(address), + queryFn: () => fetchRekeyedAddresses(address, network), enabled: !!address, - staleTime: 0, + // 30s lets `prefetchLedgerAccountPreview`'s warm-up actually pay off + // for the short-lived Ledger import session without serving + // long-stale rekey data; rescan flows invalidate this key when + // fresher data is explicitly required. + staleTime: 30_000, }) return { diff --git a/packages/accounts/src/hooks/useRescanRekeyedAccounts.ts b/packages/accounts/src/hooks/useRescanRekeyedAccounts.ts index 3b7d4be9f..7a9b8e7b5 100644 --- a/packages/accounts/src/hooks/useRescanRekeyedAccounts.ts +++ b/packages/accounts/src/hooks/useRescanRekeyedAccounts.ts @@ -11,7 +11,10 @@ */ import { useCallback } from 'react' -import { isValidAlgorandAddress } from '@perawallet/wallet-core-blockchain' +import { + isValidAlgorandAddress, + useNetwork, +} from '@perawallet/wallet-core-blockchain' import { fetchRekeyedAddresses } from '../account-discovery' import { useAccountsStore } from '../store' @@ -41,10 +44,14 @@ export const useRescanRekeyedAccounts = (): UseRescanRekeyedAccountsResult => { const addRekeyedWatchAccounts = useAccountsStore( state => state.addRekeyedWatchAccounts, ) + const { network } = useNetwork() const scan = useCallback( async (sourceAddress: string): Promise => { - const addresses = await fetchRekeyedAddresses(sourceAddress) + const addresses = await fetchRekeyedAddresses( + sourceAddress, + network, + ) // Read the wallet's account set fresh, after the indexer call — // a scan can outlive an import/add that lands while the request // is in flight, so classification must reflect the latest store @@ -66,7 +73,7 @@ export const useRescanRekeyedAccounts = (): UseRescanRekeyedAccountsResult => { notImportedAddresses: notImported, } }, - [], + [network], ) const importSelected = useCallback( From a30c983c4ca85347ca8bdc30e0674379bf62b2d1 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Wed, 20 May 2026 13:41:05 +0100 Subject: [PATCH 39/39] chore: fmt --- packages/accounts/src/__tests__/account-discovery.spec.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/accounts/src/__tests__/account-discovery.spec.ts b/packages/accounts/src/__tests__/account-discovery.spec.ts index eb6e3465c..90d48fbe1 100644 --- a/packages/accounts/src/__tests__/account-discovery.spec.ts +++ b/packages/accounts/src/__tests__/account-discovery.spec.ts @@ -301,10 +301,7 @@ describe('discoverRekeyedAccounts', () => { }, } as any) - const addresses = await fetchRekeyedAddresses( - 'AUTH_ADDRESS', - 'mainnet', - ) + const addresses = await fetchRekeyedAddresses('AUTH_ADDRESS', 'mainnet') expect(addresses).toEqual(['REKEYED_PAGE_1', 'REKEYED_PAGE_2']) expect(mockSearchForAccounts).toHaveBeenCalledTimes(2)