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..69b089a14 --- /dev/null +++ b/apps/mobile/src/__integration__/ledger-account-info-sheet.test.tsx @@ -0,0 +1,143 @@ +/* + 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 { 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 is the i18n key (integration harness doesn't + // initialise i18n — t() returns the raw key, see comment below). + await waitFor( + () => + expect( + screen.getByText('ledger.account_info.default_title'), + ).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/__integration__/ledger-imported-account-row.test.tsx b/apps/mobile/src/__integration__/ledger-imported-account-row.test.tsx new file mode 100644 index 000000000..38ba194f9 --- /dev/null +++ b/apps/mobile/src/__integration__/ledger-imported-account-row.test.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 { + 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, + }, + ], + }, + }, + ) + + const checkbox = await waitFor( + () => + 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/__integration__/ledger-rekeyed-account-import.test.tsx b/apps/mobile/src/__integration__/ledger-rekeyed-account-import.test.tsx new file mode 100644 index 000000000..f4d59f1c8 --- /dev/null +++ b/apps/mobile/src/__integration__/ledger-rekeyed-account-import.test.tsx @@ -0,0 +1,212 @@ +/* + 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/src/i18n/locales/en.json b/apps/mobile/src/i18n/locales/en.json index 29ec6980f..9da01e694 100644 --- a/apps/mobile/src/i18n/locales/en.json +++ b/apps/mobile/src/i18n/locales/en.json @@ -2130,7 +2130,20 @@ "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", + "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", + "error": "Couldn't load account information.", + "retry": "Try again" }, "verify": { "title": "Verify Account Addresses via Ledger", 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..61d53fc7e --- /dev/null +++ b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/LedgerAccountInfoContent.tsx @@ -0,0 +1,184 @@ +/* + 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 { useCallback } from 'react' +import { ActivityIndicator } from 'react-native' +import { ALGO_ASSET, ALGO_ASSET_ID } from '@perawallet/wallet-core-assets' +import { + PWView, + PWText, + PWToolbar, + PWTouchableIcon, + 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 { + useLedgerAccountInfoContent, + type LedgerInfoListItem, +} from './useLedgerAccountInfoContent' +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 { + title: resolvedTitle, + items, + isLoading, + isError, + refetch, + } = useLedgerAccountInfoContent(address, accountIndex, title) + + const renderItem = useCallback( + ({ item }: { item: LedgerInfoListItem }) => { + switch (item.kind) { + case 'sectionHeader': + return ( + + {item.title} + + ) + + case 'account': + return ( + + + + + + + + ) + + case 'asset': + return ( + + ) + + case 'rekeyAddress': + return ( + + ) + } + }, + [styles], + ) + + return ( + + + } + center={{resolvedTitle}} + /> + + {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/__tests__/useLedgerAccountInfoContent.spec.ts b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/__tests__/useLedgerAccountInfoContent.spec.ts new file mode 100644 index 000000000..da8cf4659 --- /dev/null +++ b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/__tests__/useLedgerAccountInfoContent.spec.ts @@ -0,0 +1,401 @@ +/* + 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 { + AccountTypes, + AccountLogicalTypes, +} from '@perawallet/wallet-core-accounts' +import { useLedgerAccountInfoContent } from '../useLedgerAccountInfoContent' + +const mocks = vi.hoisted(() => ({ + useLedgerAccountPreview: vi.fn(), + t: vi.fn((k: string, _opts?: Record) => k), +})) + +vi.mock('@perawallet/wallet-core-accounts', () => ({ + useLedgerAccountPreview: mocks.useLedgerAccountPreview, + AccountTypes: { + algo25: 'algo25', + hdWallet: 'hdWallet', + hardware: 'hardware', + 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: mocks.t }), +})) + +const baseAsset = { + assetId: '0', + name: 'Algo', + unitName: 'ALGO', + decimals: 6, + amount: new Decimal(5), + fiatValue: new Decimal(10), + 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() +}) + +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.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([]) + }) + + 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.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', () => { + 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', + }) + if (rekeyAddressItems[0].kind === 'rekeyAddress') { + expect(rekeyAddressItems[0].account.address).toBe('AUTH') + expect(rekeyAddressItems[0].logicalTypeOverride).toBe( + AccountLogicalTypes.LedgerBle, + ) + } + }) + + 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 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', () => { + 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 algoUsdPrice on the account item', () => { + mocks.useLedgerAccountPreview.mockReturnValue({ + preview: { + address: 'ADDR', + algoBalance: new Decimal('408.2'), + totalFiatValue: new Decimal('48.45'), + assets: [{ ...baseAsset, usdPrice: new Decimal('0.6') }], + 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.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') + } + expect(acct.logicalTypeOverride).toBe(AccountLogicalTypes.LedgerBle) + } + }) + + 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') + expect(acct.logicalTypeOverride).toBe( + AccountLogicalTypes.RekeyedAuth, + ) + } + }) + + 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') + // 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') + } + }) + + 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/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..d2502680c --- /dev/null +++ b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/styles.ts @@ -0,0 +1,56 @@ +/* + 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, + }, + /** Account-details row — mirrors AccountWithBalance layout */ + accountRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: theme.spacing.sm, + paddingVertical: theme.spacing.sm, + }, + balanceContainer: { + gap: theme.spacing.xs, + alignItems: 'flex-end', + 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, + }, + centerState: { + paddingVertical: theme.spacing['4xl'], + alignItems: 'center', + gap: theme.spacing.md, + }, +})) 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..72b13377f --- /dev/null +++ b/apps/mobile/src/modules/ledger/components/LedgerAccountInfoContent/useLedgerAccountInfoContent.ts @@ -0,0 +1,202 @@ +/* + 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 { + useLedgerAccountPreview, + AccountLogicalTypes, + AccountTypes, + type AccountLogicalType, + type AssetWithAccountBalance, + type WalletAccount, + type HardwareWalletAccount, + type WatchAccount, +} from '@perawallet/wallet-core-accounts' +import { useLanguage } from '@hooks/useLanguage' + +export type LedgerInfoListItem = + | { kind: 'sectionHeader'; key: string; title: string } + | { + kind: 'account' + key: string + account: WalletAccount + algoBalance: Decimal + algoUsdPrice: Decimal + logicalTypeOverride: AccountLogicalType + } + | { + kind: 'asset' + key: string + accountBalance: AssetWithAccountBalance + usdPrice: Decimal + } + | { + kind: 'rekeyAddress' + key: string + account: WalletAccount + logicalTypeOverride: AccountLogicalType + } + +type UseLedgerAccountInfoContentResult = { + title: string + items: LedgerInfoListItem[] + isLoading: boolean + isError: boolean + refetch: () => void +} + +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() + const { preview, isLoading, isError, refetch } = + useLedgerAccountPreview(address) + + 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', + key: 'h-details', + title: t('ledger.account_info.account_details'), + }, + { + kind: 'account', + key: 'account', + account: synthAccount, + algoBalance: preview.algoBalance, + algoUsdPrice, + logicalTypeOverride: + preview.rekey.kind === 'rekeyedTo' + ? AccountLogicalTypes.RekeyedAuth + : AccountLogicalTypes.LedgerBle, + }, + { + kind: 'sectionHeader', + key: 'h-assets', + title: t('ledger.account_info.assets'), + }, + ...preview.assets.map( + (asset): LedgerInfoListItem => ({ + kind: 'asset', + key: `asset-${asset.assetId}`, + accountBalance: { + assetId: asset.assetId, + amount: asset.amount, + // 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, + }), + ), + ] + + 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', + key: 'h-rekey', + title: t('ledger.account_info.can_be_signed_by'), + }, + { + kind: 'rekeyAddress', + key: `rekey-${preview.rekey.authAddress}`, + account: authSynthAccount, + logicalTypeOverride: AccountLogicalTypes.LedgerBle, + }, + ) + } 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 => { + // 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}`, + account: watchSynth, + logicalTypeOverride: AccountLogicalTypes.RekeyedAuth, + }) + }) + } + + return list + }, [preview, t, accountIndex]) + + return { + title: + titleOverride ?? + t('ledger.account_info.default_title', { index: accountIndex }), + items, + isLoading, + isError, + refetch, + } +} 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' /> 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..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,36 +17,40 @@ import { PWTouchableOpacity, PWCheckbox, PWChip, - PWIcon, + PWTouchableIcon, } from '@components/core' -import { useClipboard } from '@hooks/useClipboard' -import { useLanguage } from '@hooks/useLanguage' import { truncateAlgorandAddress } from '@perawallet/wallet-core-shared' +import { useLanguage } from '@hooks/useLanguage' import LightLedgerAccountIcon from '@assets/icons/accounts/light/ledger-account.svg' import { useStyles } from './styles' export type LedgerAccountSelectionRowProps = { address: string + accountIndex: number + variant?: 'derived' | 'rekeyed' isSelected: boolean isImported: boolean onToggle: () => void + onInfoPress: (address: string, accountIndex: number) => void testID?: string } export const LedgerAccountSelectionRow = ({ address, + accountIndex, + variant = 'derived', isSelected, isImported, onToggle, + onInfoPress, testID, }: 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) + }, [onInfoPress, address, accountIndex]) return ( + + - - - {truncateAlgorandAddress(address)} - - + {variant === 'rekeyed' && ( + - + )} {isImported && ( - {!isImported && ( - - )} + ) } diff --git a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/LedgerAccountSelectionRow/styles.ts b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/LedgerAccountSelectionRow/styles.ts index 0d6e71775..cc1936502 100644 --- a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/LedgerAccountSelectionRow/styles.ts +++ b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/LedgerAccountSelectionRow/styles.ts @@ -38,12 +38,6 @@ export const useStyles = makeStyles((theme, { isImported }: StyleProps) => ({ flex: 1, gap: theme.spacing.xxs, }, - addressTouchable: { - flexDirection: 'row', - alignItems: 'center', - gap: theme.spacing.xxs, - alignSelf: 'flex-start', - }, title: { color: theme.colors.textMain, }, diff --git a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/LedgerSelectAccountsScreen.tsx b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/LedgerSelectAccountsScreen.tsx index 55fd02397..5dca94ee1 100644 --- a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/LedgerSelectAccountsScreen.tsx +++ b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/LedgerSelectAccountsScreen.tsx @@ -20,7 +20,7 @@ import { PWIcon, PWFlatList, } from '@components/core' -import type { LedgerAccount } from '@perawallet/wallet-core-ledger' +import type { LedgerSelectableAccount } from '@perawallet/wallet-core-accounts' import { useLanguage } from '@hooks/useLanguage' import { useStyles } from './styles' @@ -32,7 +32,8 @@ export const LedgerSelectAccountsScreen = () => { const styles = useStyles() const { t } = useLanguage() const { - accounts, + selectableAccounts, + isScanning, selectedAddresses, isAllSelected, areAllImported, @@ -43,31 +44,52 @@ export const LedgerSelectAccountsScreen = () => { toggleSelectAll, handleContinue, handleFindAnother, + 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)} - testID={`ledger_select_row_${item.address}`} + onToggle={() => toggleSelection(address)} + onInfoPress={handleInfoPress} + testID={`ledger_select_row_${address}`} /> ) } const renderFooter = () => ( - + <> + {isScanning && ( + + {t('ledger.select_accounts.scanning_rekeyed')} + + )} + + ) return ( @@ -85,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 ef2f31914..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 @@ -78,8 +78,57 @@ vi.mock('@react-navigation/native', () => ({ }), })) +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; +// 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', () => ({ + useBottomSheet: () => ({ request: mockRequest }), +})) + +vi.mock('@perawallet/wallet-core-blockchain', () => ({ + useAlgorandClient: () => ({}), + useNetwork: () => ({ network: 'mainnet' }), +})) + +vi.mock('@tanstack/react-query', () => ({ + useQueryClient: () => mockQueryClient, })) import { useLedgerSelectAccountsScreen } from '../useLedgerSelectAccountsScreen' @@ -100,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() @@ -108,19 +164,23 @@ describe('useLedgerSelectAccountsScreen', () => { mockConnect.mockReset() mockGetProviderRegistry.mockReset() mockErrorToast.mockReset() + mockPrefetch.mockClear() + mockRequest.mockClear() const transport = buildTransport() mockConnect.mockResolvedValue(transport) mockDisconnectTransport.mockResolvedValue(undefined) mockGetProviderRegistry.mockReturnValue({ connect: mockConnect }) + 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) }) @@ -137,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, @@ -163,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 () => { @@ -214,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) }) @@ -228,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) }) @@ -319,4 +381,241 @@ 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('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()) + + 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', + ) + }) + }) + + 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, + }, + }, + ], + }) + }) + + 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 }, + ]) + }) + + 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.ts b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.tsx similarity index 56% rename from apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.ts rename to apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.tsx index 4f8e6c321..52ef34218 100644 --- a/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.ts +++ b/apps/mobile/src/modules/ledger/screens/LedgerSelectAccountsScreen/useLedgerSelectAccountsScreen.tsx @@ -10,10 +10,16 @@ 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, + useLedgerRekeyedScan, + type LedgerSelectableAccount, +} from '@perawallet/wallet-core-accounts' import type { LedgerAccount } from '@perawallet/wallet-core-ledger' import { LedgerProviderNotFoundError, @@ -21,9 +27,15 @@ 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' @@ -33,7 +45,8 @@ type LedgerSelectAccountsRouteProp = RouteProp< > type UseLedgerSelectAccountsScreenResult = { - accounts: LedgerAccount[] + selectableAccounts: LedgerSelectableAccount[] + isScanning: boolean selectedAddresses: Set isAllSelected: boolean areAllImported: boolean @@ -44,6 +57,7 @@ type UseLedgerSelectAccountsScreenResult = { toggleSelectAll: () => void handleContinue: () => void handleFindAnother: () => Promise + handleInfoPress: (address: string, accountIndex: number) => void t: (key: string, options?: Record) => string } @@ -62,6 +76,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>( @@ -72,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 @@ -91,19 +113,74 @@ 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( + () => [ + ...accounts.map( + (account): LedgerSelectableAccount => ({ + kind: 'derived', + account, + }), + ), + ...rekeyed, + ], + [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) { + 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 && - selectedAddresses.size === newAccounts.length + newAccounts.every(s => + selectedAddresses.has( + s.kind === 'derived' ? s.account.address : s.address, + ), + ) const toggleSelection = useCallback( (address: string) => { @@ -127,26 +204,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 selectedAccounts = accounts.filter(acc => - selectedAddresses.has(acc.address), + const selected = selectableAccounts.filter(s => + selectedAddresses.has( + s.kind === 'derived' ? s.account.address : s.address, + ), ) - if (selectedAccounts.length === 0) return + if (selected.length === 0) return + + 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, @@ -200,12 +301,34 @@ export const useLedgerSelectAccountsScreen = } }, [deviceId, transportType, errorToast, t]) + 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, selectableByAddress, t], + ) + const areAllImported = newAccounts.length === 0 const canContinue = !isFetchingMore && (areAllImported || selectedAddresses.size > 0) return { - accounts, + selectableAccounts, + isScanning, selectedAddresses, isAllSelected, areAllImported, @@ -216,6 +339,7 @@ export const useLedgerSelectAccountsScreen = toggleSelectAll, handleContinue, handleFindAnother, + handleInfoPress, t, } } 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< + 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(), + })) + +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) + }) + + 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 b9e09d4a4..5cdfe4e09 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,7 +146,7 @@ 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 @@ -134,21 +160,71 @@ 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) + 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) + ) { + 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, + 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 +255,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 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 diff --git a/apps/mobile/vitest.setup.ts b/apps/mobile/vitest.setup.ts index d3bca96cf..a12758008 100644 --- a/apps/mobile/vitest.setup.ts +++ b/apps/mobile/vitest.setup.ts @@ -64,6 +64,12 @@ 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: { diff --git a/packages/accounts/src/__tests__/account-discovery.spec.ts b/packages/accounts/src/__tests__/account-discovery.spec.ts index 5f10c5d0f..90d48fbe1 100644 --- a/packages/accounts/src/__tests__/account-discovery.spec.ts +++ b/packages/accounts/src/__tests__/account-discovery.spec.ts @@ -301,7 +301,7 @@ 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 +320,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__/mappers.test.ts b/packages/accounts/src/hooks/__tests__/mappers.spec.ts similarity index 79% rename from packages/accounts/src/hooks/__tests__/mappers.test.ts rename to packages/accounts/src/hooks/__tests__/mappers.spec.ts index 5784ff7c0..a45db4528 100644 --- a/packages/accounts/src/hooks/__tests__/mappers.test.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 => ({ @@ -67,4 +69,20 @@ describe('mapOnChainAccountInformation', () => { 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 mapped = mapOnChainAccountInformation( + buildResponse({ + authAddr: { toString: () => 'AUTHADDR' }, + }), + ) + + expect(mapped.authAddress).toBe('AUTHADDR') + }) }) diff --git a/packages/accounts/src/hooks/__tests__/prefetchLedgerAccountPreview.spec.ts b/packages/accounts/src/hooks/__tests__/prefetchLedgerAccountPreview.spec.ts new file mode 100644 index 000000000..11b8e9fd2 --- /dev/null +++ b/packages/accounts/src/hooks/__tests__/prefetchLedgerAccountPreview.spec.ts @@ -0,0 +1,97 @@ +/* + 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({ + defaultOptions: { queries: { retry: false } }, + }) + 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', + 'mainnet', + ) + }) + + it('never rejects when a fetch fails (best-effort)', async () => { + mocks.fetchRekeyedAddresses.mockRejectedValue(new Error('network')) + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + + await expect( + prefetchLedgerAccountPreview( + queryClient, + {} as never, + 'ADDR', + 'mainnet', + ), + ).resolves.toBeUndefined() + }) +}) diff --git a/packages/accounts/src/hooks/__tests__/useLedgerAccountPreview.spec.ts b/packages/accounts/src/hooks/__tests__/useLedgerAccountPreview.spec.ts new file mode 100644 index 000000000..b1de479b7 --- /dev/null +++ b/packages/accounts/src/hooks/__tests__/useLedgerAccountPreview.spec.ts @@ -0,0 +1,309 @@ +/* + 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({ + rekeyedAddresses: [], + 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') + 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', () => { + 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') + 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', () => { + // 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) + expect(asa?.decimals).toBe(0) + expect(asa?.usdPrice.toString()).toBe('0') + }) + + 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({ + rekeyedAddresses: ['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({ + rekeyedAddresses: ['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({ + rekeyedAddresses: 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/__tests__/useLedgerRekeyedScan.spec.ts b/packages/accounts/src/hooks/__tests__/useLedgerRekeyedScan.spec.ts new file mode 100644 index 000000000..f94753a38 --- /dev/null +++ b/packages/accounts/src/hooks/__tests__/useLedgerRekeyedScan.spec.ts @@ -0,0 +1,89 @@ +/* + 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/__tests__/useRekeyedAddressesQuery.spec.ts b/packages/accounts/src/hooks/__tests__/useRekeyedAddressesQuery.spec.ts new file mode 100644 index 000000000..d77200e83 --- /dev/null +++ b/packages/accounts/src/hooks/__tests__/useRekeyedAddressesQuery.spec.ts @@ -0,0 +1,86 @@ +/* + 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.rekeyedAddresses).toEqual([ + 'REKEYED1', + 'REKEYED2', + ]), + ) + expect(result.current.isError).toBe(false) + expect(mocks.fetchRekeyedAddresses).toHaveBeenCalledWith( + 'ADDR', + 'mainnet', + ) + }) + + it('is disabled when address is empty', () => { + const { result } = renderHook(() => useRekeyedAddressesQuery(''), { + wrapper: createWrapper(), + }) + + expect(result.current.rekeyedAddresses).toBeUndefined() + expect(result.current.isLoading).toBe(false) + expect(mocks.fetchRekeyedAddresses).not.toHaveBeenCalled() + }) +}) diff --git a/packages/accounts/src/hooks/index.ts b/packages/accounts/src/hooks/index.ts index 8ba1404ff..b2114e00f 100644 --- a/packages/accounts/src/hooks/index.ts +++ b/packages/accounts/src/hooks/index.ts @@ -40,6 +40,10 @@ export * from './useSortedAssetBalances' export * from './useAllAccountLogicalTypes' export * from './useAccountLogicalType' 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/mappers.ts b/packages/accounts/src/hooks/mappers.ts index 233c00e86..b3429eead 100644 --- a/packages/accounts/src/hooks/mappers.ts +++ b/packages/accounts/src/hooks/mappers.ts @@ -13,17 +13,32 @@ 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 => ({ - 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 OnChainAccountInformationResponse & WithAuthAddr + ).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/accounts/src/hooks/prefetchLedgerAccountPreview.ts b/packages/accounts/src/hooks/prefetchLedgerAccountPreview.ts new file mode 100644 index 000000000..675039cf3 --- /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, network), + }), + ]) +} 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/useLedgerAccountPreview.ts b/packages/accounts/src/hooks/useLedgerAccountPreview.ts new file mode 100644 index 000000000..0a0267116 --- /dev/null +++ b/packages/accounts/src/hooks/useLedgerAccountPreview.ts @@ -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 { 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', + decimals: ALGO_ASSET.decimals, + amount: algoBalance, + fiatValue: usdToPreferred(algoBalance.times(algoUsdPrice)), + usdPrice: 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 ?? '', + decimals, + amount, + fiatValue: usdToPreferred(usdValue), + usdPrice, + 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.rekeyedAddresses && + rekeyed.rekeyedAddresses.length > 0 + ) { + rekey = { kind: 'canSignFor', addresses: rekeyed.rekeyedAddresses } + } + + return { + address, + algoBalance, + totalFiatValue: usdToPreferred(totalUsd), + assets: previewAssets, + rekey, + } + }, [ + address, + onChain.data, + assets, + prices, + rekeyed.rekeyedAddresses, + rekeyed.isError, + usdToPreferred, + ]) + + return { + preview, + isLoading: onChain.isLoading, + isError: onChain.isError, + refetch: onChain.refetch, + } +} diff --git a/packages/accounts/src/hooks/useLedgerRekeyedScan.ts b/packages/accounts/src/hooks/useLedgerRekeyedScan.ts new file mode 100644 index 000000000..ae47bed85 --- /dev/null +++ b/packages/accounts/src/hooks/useLedgerRekeyedScan.ts @@ -0,0 +1,94 @@ +/* + 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 and returns them as `rekeyed` selectables. + * + * 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[], +): UseLedgerRekeyedScanResult => { + const { network } = useNetwork() + const allAccounts = useAllAccounts() + + const results = useQueries({ + queries: derivedAccounts.map(acc => ({ + queryKey: getRekeyedAddressesQueryKey(acc.address, network), + 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)) + 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 } + // `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 new file mode 100644 index 000000000..1cb0f7e25 --- /dev/null +++ b/packages/accounts/src/hooks/useRekeyedAddressesQuery.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 { useQuery } from '@tanstack/react-query' +import { useNetwork } from '@perawallet/wallet-core-blockchain' +import { fetchRekeyedAddresses } from '../account-discovery' +import { getRekeyedAddressesQueryKey } from './querykeys' + +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() + + const query = useQuery({ + queryKey: getRekeyedAddressesQueryKey(address, network), + queryFn: () => fetchRekeyedAddresses(address, network), + enabled: !!address, + // 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 { + rekeyedAddresses: query.data, + isLoading: query.isLoading, + isError: query.isError, + refetch: () => { + void query.refetch() + }, + } +} 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( diff --git a/packages/accounts/src/models/index.ts b/packages/accounts/src/models/index.ts index aa84539b3..c54656040 100644 --- a/packages/accounts/src/models/index.ts +++ b/packages/accounts/src/models/index.ts @@ -15,6 +15,8 @@ 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-account-preview.ts b/packages/accounts/src/models/ledger-account-preview.ts new file mode 100644 index 000000000..34c88ebf2 --- /dev/null +++ b/packages/accounts/src/models/ledger-account-preview.ts @@ -0,0 +1,54 @@ +/* + 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 + /** Asset decimals (display precision) */ + decimals: number + /** Holding amount in display units */ + 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 +} + +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 the account's on-chain holdings in algod order */ + assets: LedgerAccountPreviewAsset[] + rekey: LedgerAccountRekeyRelationship +} + +export type UseLedgerAccountPreviewResult = { + preview?: LedgerAccountPreview + isLoading: boolean + isError: boolean + refetch: () => void +} 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 + } 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