diff --git a/apps/mobile/src/components/AddressDisplay/__tests__/AddressDisplay.spec.tsx b/apps/mobile/src/components/AddressDisplay/__tests__/AddressDisplay.spec.tsx index 95d70cfa5..29bd2d799 100644 --- a/apps/mobile/src/components/AddressDisplay/__tests__/AddressDisplay.spec.tsx +++ b/apps/mobile/src/components/AddressDisplay/__tests__/AddressDisplay.spec.tsx @@ -20,19 +20,15 @@ const mockFindContacts = vi.fn(() => [] as unknown[]) vi.mock('@perawallet/wallet-core-accounts', () => ({ useAllAccounts: () => mockUseAllAccounts(), - useAccountLogicalType: () => 'Algo25', + useCanSignWith: () => true, + useRekeyAccount: () => null, + useSignerFor: () => null, AccountTypes: { - software: 'software', + algo25: 'algo25', + hdWallet: 'hdWallet', hardware: 'hardware', - }, - AccountLogicalTypes: { - Algo25: 'Algo25', - HdKey: 'HdKey', - LedgerBle: 'LedgerBle', - Multisig: 'Multisig', - Rekeyed: 'Rekeyed', - RekeyedAuth: 'RekeyedAuth', - NoAuth: 'NoAuth', + multisig: 'multisig', + watch: 'watch', }, isMultisigAccount: () => false, })) diff --git a/apps/mobile/src/modules/accounts/components/AccountAssetList/useAccountAssetList.tsx b/apps/mobile/src/modules/accounts/components/AccountAssetList/useAccountAssetList.tsx index ee252142a..5f28d5aec 100644 --- a/apps/mobile/src/modules/accounts/components/AccountAssetList/useAccountAssetList.tsx +++ b/apps/mobile/src/modules/accounts/components/AccountAssetList/useAccountAssetList.tsx @@ -14,9 +14,8 @@ import { useCallback, useEffect, useMemo } from 'react' import { ParamListBase, useNavigation } from '@react-navigation/native' import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { - isSigningLogicalType, useAccountBalancesQuery, - useAccountLogicalType, + useCanSignWith, useSortedAssetBalances, WalletAccount, AssetWithAccountBalance, @@ -152,8 +151,7 @@ export const useAccountAssetList = ({ return sortedBalances.filter(b => matchingAssetIds.has(b.assetId)) }, [sortedBalances, searchFilter, matchingAssetIds]) - const logicalType = useAccountLogicalType(account.address) - const isWatch = !logicalType || !isSigningLogicalType(logicalType) + const isWatch = !useCanSignWith(account) const goToAssetScreen = useCallback( (item: AssetWithAccountBalance) => { diff --git a/apps/mobile/src/modules/accounts/components/AccountIcon/AccountIcon.tsx b/apps/mobile/src/modules/accounts/components/AccountIcon/AccountIcon.tsx index 7e9dbbb2d..8472bb8cb 100644 --- a/apps/mobile/src/modules/accounts/components/AccountIcon/AccountIcon.tsx +++ b/apps/mobile/src/modules/accounts/components/AccountIcon/AccountIcon.tsx @@ -15,8 +15,9 @@ import { IconName, PWIcon, PWView } from '@components/core' import { useMemo } from 'react' import { AccountTypes, - useAccountLogicalType, - type AccountLogicalType, + useCanSignWith, + useRekeyAccount, + type AccountType, type WalletAccount, } from '@perawallet/wallet-core-accounts' import { useIsDarkMode } from '@hooks/useIsDarkMode' @@ -29,51 +30,51 @@ const FALLBACK_ASSET = `accounts/${THEME_TOKEN}/unknown-account` export type AccountIconProps = { account?: WalletAccount size?: AccountIconSize - logicalTypeOverride?: AccountLogicalType /** - * Override the resolved logical type. Useful when projecting a different - * state than what's currently stored — e.g. showing a post-undo-rekey - * preview where the source should appear as its base type. + * When true, render the icon for the account's base `type` and ignore + * its rekey state. Used by the undo-rekey preview to show what the + * source would look like once the rekey is undone. */ + ignoreRekey?: boolean } & SvgProps -const iconNames = { - Algo25: `accounts/${THEME_TOKEN}/algo25-account`, - HdKey: `accounts/${THEME_TOKEN}/hdwallet-account`, - LedgerBle: `accounts/${THEME_TOKEN}/ledger-account`, - Multisig: `accounts/${THEME_TOKEN}/multisig-account`, - Rekeyed: `accounts/${THEME_TOKEN}/noauth-account`, - RekeyedAuth: `accounts/${THEME_TOKEN}/rekeyed-standard`, - NoAuth: `accounts/${THEME_TOKEN}/watch-account`, -} satisfies Record +const BASE_ICON: Record = { + [AccountTypes.algo25]: `accounts/${THEME_TOKEN}/algo25-account`, + [AccountTypes.hdWallet]: `accounts/${THEME_TOKEN}/hdwallet-account`, + [AccountTypes.hardware]: `accounts/${THEME_TOKEN}/ledger-account`, + [AccountTypes.multisig]: `accounts/${THEME_TOKEN}/multisig-account`, + [AccountTypes.watch]: `accounts/${THEME_TOKEN}/watch-account`, +} -const resolveIconAsset = ( - logicalType: AccountLogicalType, - account: WalletAccount, -): string => { - if (logicalType === 'RekeyedAuth') { - if (account.type === AccountTypes.hardware) { - return `accounts/${THEME_TOKEN}/rekeyed-ledger` - } - if (account.type === AccountTypes.multisig) { - return `accounts/${THEME_TOKEN}/rekeyed-multisig` - } - } - return iconNames[logicalType] ?? FALLBACK_ASSET +const REKEYED_SIGNABLE_ICON: Partial> = { + [AccountTypes.hardware]: `accounts/${THEME_TOKEN}/rekeyed-ledger`, + [AccountTypes.multisig]: `accounts/${THEME_TOKEN}/rekeyed-multisig`, } +const REKEYED_SIGNABLE_DEFAULT = `accounts/${THEME_TOKEN}/rekeyed-standard` +const REKEYED_UNSIGNABLE_ICON = `accounts/${THEME_TOKEN}/noauth-account` export const AccountIcon = (props: AccountIconProps) => { - const { account, size = 'md', logicalTypeOverride, ...rest } = props + const { account, size = 'md', ignoreRekey, ...rest } = props const darkmode = useIsDarkMode() - const resolvedType = useAccountLogicalType(account?.address) - const logicalType = logicalTypeOverride ?? resolvedType + const rekeyAccount = useRekeyAccount(account?.address) + const canSign = useCanSignWith(account) const styles = useStyles({ size }) const icon = useMemo(() => { - if (!account || !logicalType) return null + if (!account) return null + + const isRekeyed = !ignoreRekey && !!account.rekeyAddress + let asset: string + if (isRekeyed && canSign) { + asset = + REKEYED_SIGNABLE_ICON[account.type] ?? REKEYED_SIGNABLE_DEFAULT + } else if (isRekeyed && !canSign) { + asset = REKEYED_UNSIGNABLE_ICON + } else { + asset = BASE_ICON[account.type] ?? FALLBACK_ASSET + } const theme = darkmode ? 'dark' : 'light' - const asset = resolveIconAsset(logicalType, account) const iconName: IconName = asset.replaceAll( THEME_TOKEN, theme, @@ -85,7 +86,10 @@ export const AccountIcon = (props: AccountIconProps) => { size={size} /> ) - }, [account, logicalType, darkmode, rest, size]) + // rekeyAccount keeps the memo invalidating when the auth account + // changes (which can flip canSign). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [account, ignoreRekey, canSign, rekeyAccount, darkmode, size, rest]) if (!icon) return <> diff --git a/apps/mobile/src/modules/accounts/components/AccountInfoCard/AccountTypeInfoContent/__tests__/useAccountTypeInfo.spec.ts b/apps/mobile/src/modules/accounts/components/AccountInfoCard/AccountTypeInfoContent/__tests__/useAccountTypeInfo.spec.ts index d297572f6..5bffd6212 100644 --- a/apps/mobile/src/modules/accounts/components/AccountInfoCard/AccountTypeInfoContent/__tests__/useAccountTypeInfo.spec.ts +++ b/apps/mobile/src/modules/accounts/components/AccountInfoCard/AccountTypeInfoContent/__tests__/useAccountTypeInfo.spec.ts @@ -14,7 +14,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { renderHook, act } from '@testing-library/react' import { useAccountTypeInfo } from '../useAccountTypeInfo' import type { - AccountLogicalType, RekeyTransition, WalletAccount, } from '@perawallet/wallet-core-accounts' @@ -47,7 +46,7 @@ vi.mock('@perawallet/wallet-core-config', () => ({ }, })) -const mockUseAccountLogicalType = vi.fn<() => AccountLogicalType | null>() +const mockUseCanSignWith = vi.fn<() => boolean>() const mockUseRekeyTransition = vi.fn<() => RekeyTransition | null>() vi.mock('@perawallet/wallet-core-accounts', async importOriginal => { const actual = @@ -56,28 +55,32 @@ vi.mock('@perawallet/wallet-core-accounts', async importOriginal => { >() return { ...actual, - useAccountLogicalType: () => mockUseAccountLogicalType(), + useCanSignWith: () => mockUseCanSignWith(), useRekeyTransition: () => mockUseRekeyTransition(), } }) -const algo25Account: WalletAccount = { - type: 'algo25', - address: 'ALGO25ADDR', - keyPairId: 'key-1', -} +const accountOfType = ( + type: WalletAccount['type'], + rekeyAddress?: string, +): WalletAccount => + ({ + type, + address: `${type.toUpperCase()}_ADDR`, + keyPairId: 'key-1', + rekeyAddress, + }) as WalletAccount describe('useAccountTypeInfo', () => { beforeEach(() => { vi.clearAllMocks() - mockUseAccountLogicalType.mockReturnValue('Algo25') + mockUseCanSignWith.mockReturnValue(true) mockUseRekeyTransition.mockReturnValue(null) }) - it('resolves Algo25 account type', () => { - mockUseAccountLogicalType.mockReturnValue('Algo25') + it('resolves algo25 account type', () => { const { result } = renderHook(() => - useAccountTypeInfo({ account: algo25Account }), + useAccountTypeInfo({ account: accountOfType('algo25') }), ) expect(result.current.title).toBe('account_type_info.standard_title') @@ -86,10 +89,9 @@ describe('useAccountTypeInfo', () => { ) }) - it('resolves LedgerBle account type', () => { - mockUseAccountLogicalType.mockReturnValue('LedgerBle') + it('resolves hardware account type', () => { const { result } = renderHook(() => - useAccountTypeInfo({ account: algo25Account }), + useAccountTypeInfo({ account: accountOfType('hardware') }), ) expect(result.current.title).toBe('account_type_info.ledger_title') @@ -98,10 +100,9 @@ describe('useAccountTypeInfo', () => { ) }) - it('resolves HdKey account type', () => { - mockUseAccountLogicalType.mockReturnValue('HdKey') + it('resolves hdWallet account type', () => { const { result } = renderHook(() => - useAccountTypeInfo({ account: algo25Account }), + useAccountTypeInfo({ account: accountOfType('hdWallet') }), ) expect(result.current.title).toBe('account_type_info.hd_wallet_title') @@ -110,10 +111,9 @@ describe('useAccountTypeInfo', () => { ) }) - it('resolves Multisig account type', () => { - mockUseAccountLogicalType.mockReturnValue('Multisig') + it('resolves multisig account type', () => { const { result } = renderHook(() => - useAccountTypeInfo({ account: algo25Account }), + useAccountTypeInfo({ account: accountOfType('multisig') }), ) expect(result.current.title).toBe('account_type_info.multisig_title') @@ -122,11 +122,11 @@ describe('useAccountTypeInfo', () => { ) }) - it('resolves RekeyedAuth account type without a known auth account as generic rekeyed', () => { - mockUseAccountLogicalType.mockReturnValue('RekeyedAuth') + it('resolves signable rekeyed account without a known auth as generic rekeyed', () => { + mockUseCanSignWith.mockReturnValue(true) mockUseRekeyTransition.mockReturnValue(null) const { result } = renderHook(() => - useAccountTypeInfo({ account: algo25Account }), + useAccountTypeInfo({ account: accountOfType('algo25', 'AUTH') }), ) expect(result.current.title).toBe( @@ -138,13 +138,13 @@ describe('useAccountTypeInfo', () => { }) it('resolves a standard-to-ledger rekey with the split transition title', () => { - mockUseAccountLogicalType.mockReturnValue('RekeyedAuth') + mockUseCanSignWith.mockReturnValue(true) mockUseRekeyTransition.mockReturnValue({ - from: 'Algo25', - to: 'LedgerBle', + from: 'algo25', + to: 'hardware', }) const { result } = renderHook(() => - useAccountTypeInfo({ account: algo25Account }), + useAccountTypeInfo({ account: accountOfType('algo25', 'AUTH') }), ) expect(result.current.title).toBe('Rekeyed') @@ -155,13 +155,13 @@ describe('useAccountTypeInfo', () => { }) it('resolves a ledger-to-ledger rekey with the ledger-to-ledger description', () => { - mockUseAccountLogicalType.mockReturnValue('RekeyedAuth') + mockUseCanSignWith.mockReturnValue(true) mockUseRekeyTransition.mockReturnValue({ - from: 'LedgerBle', - to: 'LedgerBle', + from: 'hardware', + to: 'hardware', }) const { result } = renderHook(() => - useAccountTypeInfo({ account: algo25Account }), + useAccountTypeInfo({ account: accountOfType('hardware', 'AUTH') }), ) expect(result.current.description).toBe( @@ -169,19 +169,19 @@ describe('useAccountTypeInfo', () => { ) }) - it('resolves Rekeyed account type as No Auth (locked-out)', () => { - mockUseAccountLogicalType.mockReturnValue('Rekeyed') + it('resolves an unsignable rekeyed account as No Auth', () => { + mockUseCanSignWith.mockReturnValue(false) + mockUseRekeyTransition.mockReturnValue(null) const { result } = renderHook(() => - useAccountTypeInfo({ account: algo25Account }), + useAccountTypeInfo({ account: accountOfType('algo25', 'AUTH') }), ) expect(result.current.title).toBe('account_type_info.no_auth_title') }) - it('resolves NoAuth account type as Watch', () => { - mockUseAccountLogicalType.mockReturnValue('NoAuth') + it('resolves watch account type', () => { const { result } = renderHook(() => - useAccountTypeInfo({ account: algo25Account }), + useAccountTypeInfo({ account: accountOfType('watch') }), ) expect(result.current.title).toBe('account_type_info.watch_title') @@ -191,9 +191,8 @@ describe('useAccountTypeInfo', () => { }) it('opens webview with support URL when learn more is pressed', () => { - mockUseAccountLogicalType.mockReturnValue('Algo25') const { result } = renderHook(() => - useAccountTypeInfo({ account: algo25Account }), + useAccountTypeInfo({ account: accountOfType('algo25') }), ) act(() => { @@ -206,9 +205,8 @@ describe('useAccountTypeInfo', () => { }) it('opens webview with rekey article when learn more is pressed for Ledger', () => { - mockUseAccountLogicalType.mockReturnValue('LedgerBle') const { result } = renderHook(() => - useAccountTypeInfo({ account: algo25Account }), + useAccountTypeInfo({ account: accountOfType('hardware') }), ) act(() => { diff --git a/apps/mobile/src/modules/accounts/components/AccountInfoCard/AccountTypeInfoContent/useAccountTypeInfo.ts b/apps/mobile/src/modules/accounts/components/AccountInfoCard/AccountTypeInfoContent/useAccountTypeInfo.ts index a904f701a..2bb5493c3 100644 --- a/apps/mobile/src/modules/accounts/components/AccountInfoCard/AccountTypeInfoContent/useAccountTypeInfo.ts +++ b/apps/mobile/src/modules/accounts/components/AccountInfoCard/AccountTypeInfoContent/useAccountTypeInfo.ts @@ -12,9 +12,10 @@ import { useCallback, useMemo } from 'react' import { - useAccountLogicalType, + AccountTypes, + useCanSignWith, useRekeyTransition, - type AccountLogicalType, + type AccountType, type WalletAccount, } from '@perawallet/wallet-core-accounts' import { useLanguage } from '@hooks/useLanguage' @@ -40,39 +41,37 @@ type UseAccountTypeInfoResult = { handleLearnMore: () => void } -const I18N_MAP = { - Algo25: { +const TYPE_I18N: Record = { + [AccountTypes.algo25]: { title: 'account_type_info.standard_title', description: 'account_type_info.standard_description', }, - HdKey: { + [AccountTypes.hdWallet]: { title: 'account_type_info.hd_wallet_title', description: 'account_type_info.hd_wallet_description', }, - LedgerBle: { + [AccountTypes.hardware]: { title: 'account_type_info.ledger_title', description: 'account_type_info.ledger_description', }, - Multisig: { + [AccountTypes.multisig]: { title: 'account_type_info.multisig_title', description: 'account_type_info.multisig_description', }, - Rekeyed: { - title: 'account_type_info.no_auth_title', - description: 'account_type_info.no_auth_description', - }, - RekeyedAuth: { - title: 'account_type_info.rekeyed_standard_title', - description: 'account_type_info.rekeyed_standard_description', - }, - NoAuth: { + [AccountTypes.watch]: { title: 'account_type_info.watch_title', description: 'account_type_info.watch_description', }, -} satisfies Record +} -const LEARN_MORE_URL_MAP: Partial> = { - LedgerBle: config.ledgerAccountSupportUrl, +const REKEYED_UNSIGNABLE_I18N = { + title: 'account_type_info.no_auth_title', + description: 'account_type_info.no_auth_description', +} + +const REKEYED_SIGNABLE_I18N = { + title: 'account_type_info.rekeyed_standard_title', + description: 'account_type_info.rekeyed_standard_description', } export const useAccountTypeInfo = ({ @@ -80,9 +79,7 @@ export const useAccountTypeInfo = ({ }: UseAccountTypeInfoParams): UseAccountTypeInfoResult => { const { t } = useLanguage() const { pushWebView } = useWebView() - const logicalType: AccountLogicalType = - useAccountLogicalType(account.address) ?? 'NoAuth' - + const canSign = useCanSignWith(account) const rekeyTransition = useRekeyTransition(account.address) const { title, titleQualifier, description } = useMemo(() => { @@ -97,18 +94,33 @@ export const useAccountTypeInfo = ({ description: t(descriptionKey), } } + + if (account.rekeyAddress) { + const i18n = canSign + ? REKEYED_SIGNABLE_I18N + : REKEYED_UNSIGNABLE_I18N + return { + title: t(i18n.title), + titleQualifier: null, + description: t(i18n.description), + } + } + + const i18n = TYPE_I18N[account.type] return { - title: t(I18N_MAP[logicalType].title), + title: t(i18n.title), titleQualifier: null, - description: t(I18N_MAP[logicalType].description), + description: t(i18n.description), } - }, [rekeyTransition, logicalType, t]) + }, [account, canSign, rekeyTransition, t]) const handleLearnMore = useCallback(() => { const url = - LEARN_MORE_URL_MAP[logicalType] ?? config.accountTypeSupportUrl + account.type === AccountTypes.hardware + ? config.ledgerAccountSupportUrl + : config.accountTypeSupportUrl pushWebView({ url }) - }, [pushWebView, logicalType]) + }, [pushWebView, account.type]) return { title, diff --git a/apps/mobile/src/modules/accounts/components/AccountInfoCard/__tests__/useAccountInfoCard.spec.ts b/apps/mobile/src/modules/accounts/components/AccountInfoCard/__tests__/useAccountInfoCard.spec.ts index eeb1eeeb5..aea2b7e37 100644 --- a/apps/mobile/src/modules/accounts/components/AccountInfoCard/__tests__/useAccountInfoCard.spec.ts +++ b/apps/mobile/src/modules/accounts/components/AccountInfoCard/__tests__/useAccountInfoCard.spec.ts @@ -40,7 +40,7 @@ vi.mock('@hooks/useLanguage', () => ({ const mockUseAccountInformationQuery = vi.fn() const mockUseHDWalletGroups = vi.fn() const mockUseLedgerDeviceGroups = vi.fn() -const mockUseAccountLogicalType = vi.fn() +const mockUseCanSignWith = vi.fn<() => boolean>() const mockUseRekeyTransition = vi.fn<() => RekeyTransition | null>() vi.mock('@perawallet/wallet-core-accounts', async importOriginal => { @@ -54,8 +54,7 @@ vi.mock('@perawallet/wallet-core-accounts', async importOriginal => { mockUseAccountInformationQuery(...args), useHDWalletGroups: () => mockUseHDWalletGroups(), useLedgerDeviceGroups: () => mockUseLedgerDeviceGroups(), - useAccountLogicalType: (...args: unknown[]) => - mockUseAccountLogicalType(...args), + useCanSignWith: () => mockUseCanSignWith(), useRekeyTransition: () => mockUseRekeyTransition(), } }) @@ -132,13 +131,7 @@ describe('useAccountInfoCard', () => { ], hasMultipleLedgerDevices: false, }) - mockUseAccountLogicalType.mockImplementation((address: string) => { - if (address === hdAccount.address) return 'HdKey' - if (address === ledgerAccount.address) return 'LedgerBle' - if (address === watchAccount.address) return 'NoAuth' - if (address === multisigAccount.address) return 'Multisig' - return null - }) + mockUseCanSignWith.mockReturnValue(true) mockUseRekeyTransition.mockReturnValue(null) }) @@ -223,29 +216,27 @@ describe('useAccountInfoCard', () => { ) }) - test('RekeyedAuth account with a transition shows the "Rekeyed (from to to)" label', () => { - mockUseAccountLogicalType.mockImplementation((address: string) => - address === ledgerAccount.address ? 'RekeyedAuth' : null, - ) + test('RekeyedSignable account with a transition shows the "Rekeyed (from to to)" label', () => { + mockUseCanSignWith.mockReturnValue(true) mockUseRekeyTransition.mockReturnValue({ - from: 'Algo25', - to: 'LedgerBle', + from: 'algo25', + to: 'hardware', }) + const rekeyed = { ...ledgerAccount, rekeyAddress: 'AUTH' } const { result } = renderHook(() => - useAccountInfoCard({ account: ledgerAccount, onClose: vi.fn() }), + useAccountInfoCard({ account: rekeyed, onClose: vi.fn() }), ) expect(result.current.accountType.label).toBe( 'account_info.type_rekeyed_transition', ) }) - test('RekeyedAuth account without a known auth account falls back to generic label', () => { - mockUseAccountLogicalType.mockImplementation((address: string) => - address === ledgerAccount.address ? 'RekeyedAuth' : null, - ) + test('RekeyedSignable account without a known auth account falls back to generic label', () => { + mockUseCanSignWith.mockReturnValue(true) mockUseRekeyTransition.mockReturnValue(null) + const rekeyed = { ...ledgerAccount, rekeyAddress: 'AUTH' } const { result } = renderHook(() => - useAccountInfoCard({ account: ledgerAccount, onClose: vi.fn() }), + useAccountInfoCard({ account: rekeyed, onClose: vi.fn() }), ) expect(result.current.accountType.label).toBe( 'account_info.type_rekeyed', diff --git a/apps/mobile/src/modules/accounts/components/AccountInfoCard/useAccountInfoCard.ts b/apps/mobile/src/modules/accounts/components/AccountInfoCard/useAccountInfoCard.ts index fc071ee23..b9ed66392 100644 --- a/apps/mobile/src/modules/accounts/components/AccountInfoCard/useAccountInfoCard.ts +++ b/apps/mobile/src/modules/accounts/components/AccountInfoCard/useAccountInfoCard.ts @@ -12,12 +12,10 @@ import { useCallback, useMemo, useState } from 'react' import { - AccountTypes, WalletAccount, isHDWalletAccount, isLedgerAccount, - isSigningLogicalType, - useAccountLogicalType, + useCanSignWith, useHDWalletGroups, useLedgerDeviceGroups, useAccountInformationQuery, @@ -66,11 +64,7 @@ export const useAccountInfoCard = ({ const isHDWallet = isHDWalletAccount(account) const isLedger = isLedgerAccount(account) - const isMultisig = account.type === AccountTypes.multisig - const logicalType = - useAccountLogicalType(account.address) ?? - (isMultisig ? 'Multisig' : 'NoAuth') - const showMinBalance = isSigningLogicalType(logicalType) + const showMinBalance = useCanSignWith(account) const accountType = useAccountTypeLabel(account) const handleToggleExpanded = useCallback(() => { diff --git a/apps/mobile/src/modules/accounts/components/AccountNfts/__tests__/useAccountNfts.spec.tsx b/apps/mobile/src/modules/accounts/components/AccountNfts/__tests__/useAccountNfts.spec.tsx index ad9be3a19..b8ac99142 100644 --- a/apps/mobile/src/modules/accounts/components/AccountNfts/__tests__/useAccountNfts.spec.tsx +++ b/apps/mobile/src/modules/accounts/components/AccountNfts/__tests__/useAccountNfts.spec.tsx @@ -51,7 +51,7 @@ vi.mock('@perawallet/wallet-core-shared', async () => { const mockUseSelectedAccount = vi.fn() const mockUseAccountBalancesQuery = vi.fn() -const mockUseAccountLogicalType = vi.fn() +const mockUseCanSignWith = vi.fn() const mockUseAllAccounts = vi.fn() vi.mock('@perawallet/wallet-core-accounts', async importOriginal => { @@ -66,8 +66,7 @@ vi.mock('@perawallet/wallet-core-accounts', async importOriginal => { useAccountBalancesQuery: (...args: unknown[]) => mockUseAccountBalancesQuery(...args), useAllAccounts: (...args: unknown[]) => mockUseAllAccounts(...args), - useAccountLogicalType: (...args: unknown[]) => - mockUseAccountLogicalType(...args), + useCanSignWith: (...args: unknown[]) => mockUseCanSignWith(...args), } }) @@ -147,7 +146,7 @@ describe('useAccountNfts', () => { mockShowOptedIn = false mockUseSelectedAccount.mockReturnValue(mockAccount) mockUseAllAccounts.mockReturnValue([mockAccount]) - mockUseAccountLogicalType.mockReturnValue('Algo25') + mockUseCanSignWith.mockReturnValue(true) mockUseAccountBalancesQuery.mockReturnValue({ accountBalances: new Map([ @@ -381,14 +380,14 @@ describe('useAccountNfts', () => { describe('canOptIn', () => { it('returns true for signing accounts', () => { - mockUseAccountLogicalType.mockReturnValue('Algo25') + mockUseCanSignWith.mockReturnValue(true) const { result } = renderHook(() => useAccountNfts()) expect(result.current.canOptIn).toBe(true) }) it('returns false for non-signing accounts', () => { - mockUseAccountLogicalType.mockReturnValue('NoAuth') + mockUseCanSignWith.mockReturnValue(false) const { result } = renderHook(() => useAccountNfts()) expect(result.current.canOptIn).toBe(false) diff --git a/apps/mobile/src/modules/accounts/components/AccountNfts/useAccountNfts.tsx b/apps/mobile/src/modules/accounts/components/AccountNfts/useAccountNfts.tsx index 49bcb0427..c88b04432 100644 --- a/apps/mobile/src/modules/accounts/components/AccountNfts/useAccountNfts.tsx +++ b/apps/mobile/src/modules/accounts/components/AccountNfts/useAccountNfts.tsx @@ -17,8 +17,7 @@ import { PWFlatList } from '@components/core' import { useSelectedAccount, useAccountBalancesQuery, - useAccountLogicalType, - isSigningLogicalType, + useCanSignWith, } from '@perawallet/wallet-core-accounts' import { useAssetsQuery, @@ -104,7 +103,7 @@ const sortCollectibles = ( export const useAccountNfts = (): UseAccountNftsResult => { const account = useSelectedAccount() - const logicalType = useAccountLogicalType(account?.address) + const canOptIn = useCanSignWith(account) const [searchFilter, setSearchFilter] = useState('') const sortMode = useCollectiblePreferencesStore( @@ -185,11 +184,6 @@ export const useAccountNfts = (): UseAccountNftsResult => { } }, [requestBottomSheet, openSortSheet, openFilterSheet]) - const canOptIn = useMemo( - () => !!logicalType && isSigningLogicalType(logicalType), - [logicalType], - ) - const { accountBalances, isPending } = useAccountBalancesQuery( account ? [account] : [], ) diff --git a/apps/mobile/src/modules/accounts/components/AccountOptionsContent/__tests__/useAccountOptions.spec.ts b/apps/mobile/src/modules/accounts/components/AccountOptionsContent/__tests__/useAccountOptions.spec.ts index 061539ea2..5cf0fbd51 100644 --- a/apps/mobile/src/modules/accounts/components/AccountOptionsContent/__tests__/useAccountOptions.spec.ts +++ b/apps/mobile/src/modules/accounts/components/AccountOptionsContent/__tests__/useAccountOptions.spec.ts @@ -33,9 +33,9 @@ const { mockAllAccounts } = vi.hoisted(() => ({ const { mockUpdateAccount } = vi.hoisted(() => ({ mockUpdateAccount: vi.fn(), })) -const { mockUseAccountLogicalType } = vi.hoisted(() => ({ - mockUseAccountLogicalType: vi.fn<(address?: string) => string | null>( - () => 'Algo25', +const { mockUseCanSignWith } = vi.hoisted(() => ({ + mockUseCanSignWith: vi.fn<(account?: WalletAccount | null) => boolean>( + () => true, ), })) const { mockRequestBottomSheet } = vi.hoisted(() => ({ @@ -102,8 +102,8 @@ vi.mock('@perawallet/wallet-core-accounts', async importOriginal => { useRemoveAccountById: () => mockRemoveAccountById, useUpdateAccount: () => mockUpdateAccount, useAllAccounts: () => mockAllAccounts(), - useAccountLogicalType: (address?: string) => - mockUseAccountLogicalType(address), + useCanSignWith: (account?: WalletAccount | null) => + mockUseCanSignWith(account), } }) @@ -167,22 +167,16 @@ describe('useAccountOptions', () => { vi.clearAllMocks() mockIsAccountEnabled.mockReturnValue(true) mockAllAccounts.mockReturnValue([algo25Account, watchAccount]) - mockUseAccountLogicalType.mockImplementation((address?: string) => { - switch (address) { + mockUseCanSignWith.mockImplementation(account => { + switch (account?.address) { case algo25Account.address: - return 'Algo25' - case watchAccount.address: - return 'NoAuth' case rekeyedAccount.address: - return 'RekeyedAuth' case rekeyedWatchAccount.address: - return 'RekeyedAuth' case hardwareAccount.address: - return 'LedgerBle' case multisigAccount.address: - return 'Multisig' + return true default: - return null + return false } }) }) diff --git a/apps/mobile/src/modules/accounts/components/AccountOptionsContent/useAccountOptions.tsx b/apps/mobile/src/modules/accounts/components/AccountOptionsContent/useAccountOptions.tsx index 0b2d808c4..a96ca0bfc 100644 --- a/apps/mobile/src/modules/accounts/components/AccountOptionsContent/useAccountOptions.tsx +++ b/apps/mobile/src/modules/accounts/components/AccountOptionsContent/useAccountOptions.tsx @@ -12,13 +12,13 @@ import { useCallback, useMemo } from 'react' import { + AccountTypes, WalletAccount, hasSigningKeys, isMultisigAccount, - isSigningLogicalType, isWatchAccount, - useAccountLogicalType, useAllAccounts, + useCanSignWith, useFindAccountByAddress, useRemoveAccountById, useUpdateAccount, @@ -79,13 +79,15 @@ export const useAccountOptions = ({ const { request: requestBottomSheet } = useBottomSheet() const { openViewPassphraseFlow } = useViewPassphraseFlow() - const logicalType = useAccountLogicalType(account.address) ?? 'NoAuth' - const showPassphrase = logicalType === 'Algo25' || logicalType === 'HdKey' - const isRekeyed = logicalType === 'Rekeyed' || logicalType === 'RekeyedAuth' - const canUndoRekey = logicalType === 'RekeyedAuth' + const canSign = useCanSignWith(account) + const isRekeyed = !!account.rekeyAddress + const showPassphrase = + !isRekeyed && + (account.type === AccountTypes.algo25 || + account.type === AccountTypes.hdWallet) + const canUndoRekey = isRekeyed && canSign const showUndoRekey = canUndoRekey - const isHdWallet = logicalType === 'HdKey' - const canSign = isSigningLogicalType(logicalType) + const isHdWallet = account.type === AccountTypes.hdWallet const isSharedAccount = isMultisigAccount(account) const participantCount = isMultisigAccount(account) ? account.multisigDetails.addresses.length diff --git a/apps/mobile/src/modules/accounts/components/AccountOverview/__tests__/useAccountOverviewHeader.spec.tsx b/apps/mobile/src/modules/accounts/components/AccountOverview/__tests__/useAccountOverviewHeader.spec.tsx index f78da48c9..3075fd63d 100644 --- a/apps/mobile/src/modules/accounts/components/AccountOverview/__tests__/useAccountOverviewHeader.spec.tsx +++ b/apps/mobile/src/modules/accounts/components/AccountOverview/__tests__/useAccountOverviewHeader.spec.tsx @@ -50,7 +50,7 @@ vi.mock('@perawallet/wallet-core-accounts', async importOriginal => { isPending: false, })), useAllAccounts: vi.fn(() => []), - useAccountLogicalType: vi.fn(() => 'Algo25'), + useCanSignWith: vi.fn(() => true), } }) @@ -101,7 +101,7 @@ describe('useAccountOverviewHeader', () => { vi.clearAllMocks() }) - it('returns canSign true when account logical type is signing', () => { + it('returns canSign true when canSignWith returns true', () => { const { result } = renderHook( () => useAccountOverviewHeader(mockAccount), { wrapper }, @@ -110,10 +110,10 @@ describe('useAccountOverviewHeader', () => { expect(result.current.canSign).toBe(true) }) - it('returns canSign false when account logical type is NoAuth', async () => { - const { useAccountLogicalType } = + it('returns canSign false when canSignWith returns false', async () => { + const { useCanSignWith } = await import('@perawallet/wallet-core-accounts') - vi.mocked(useAccountLogicalType).mockReturnValue('NoAuth') + vi.mocked(useCanSignWith).mockReturnValue(false) const { result } = renderHook( () => useAccountOverviewHeader(mockAccount), diff --git a/apps/mobile/src/modules/accounts/components/AccountOverview/useAccountOverviewHeader.ts b/apps/mobile/src/modules/accounts/components/AccountOverview/useAccountOverviewHeader.ts index e26f8161b..5d74068ba 100644 --- a/apps/mobile/src/modules/accounts/components/AccountOverview/useAccountOverviewHeader.ts +++ b/apps/mobile/src/modules/accounts/components/AccountOverview/useAccountOverviewHeader.ts @@ -14,9 +14,8 @@ import { useCallback, useMemo } from 'react' import { Decimal } from 'decimal.js' import { AccountBalanceHistoryItem, - isSigningLogicalType, useAccountBalancesQuery, - useAccountLogicalType, + useCanSignWith, usePortfolioTotals, WalletAccount, } from '@perawallet/wallet-core-accounts' @@ -45,7 +44,7 @@ export const useAccountOverviewHeader = ( account: WalletAccount, ): UseAccountOverviewHeaderResult => { const { usdToPreferred } = useCurrency() - const logicalType = useAccountLogicalType(account.address) + const canSign = useCanSignWith(account) const { portfolioAlgoValue, accountBalances, isPending } = useAccountBalancesQuery(account ? [account] : []) const { portfolioUsdValue } = usePortfolioTotals(accountBalances) @@ -80,7 +79,7 @@ export const useAccountOverviewHeader = ( setPeriod, selectedPoint, hasBalance: portfolioAlgoValue.gt(0), - canSign: !!logicalType && isSigningLogicalType(logicalType), + canSign, togglePrivacyMode, handleChartSelectionChange, } diff --git a/apps/mobile/src/modules/accounts/components/NftEmptyState/NftEmptyState.tsx b/apps/mobile/src/modules/accounts/components/NftEmptyState/NftEmptyState.tsx index 46fbfbd6e..d89101f96 100644 --- a/apps/mobile/src/modules/accounts/components/NftEmptyState/NftEmptyState.tsx +++ b/apps/mobile/src/modules/accounts/components/NftEmptyState/NftEmptyState.tsx @@ -18,8 +18,8 @@ import NftEmptyIllustration from '@assets/images/nft-empty-state.svg' type NftEmptyStateProps = { /** - * Hides the opt-in CTA when omitted. Watch / NoAuth accounts can't - * sign opt-in transactions, so the button would be a dead-end. + * Hides the opt-in CTA when omitted. Unsignable accounts can't sign + * opt-in transactions, so the button would be a dead-end. */ onOptInPress?: () => void } diff --git a/apps/mobile/src/modules/accounts/hooks/__tests__/useAccountTypeLabel.spec.ts b/apps/mobile/src/modules/accounts/hooks/__tests__/useAccountTypeLabel.spec.ts index a84b6abe2..32c38e97a 100644 --- a/apps/mobile/src/modules/accounts/hooks/__tests__/useAccountTypeLabel.spec.ts +++ b/apps/mobile/src/modules/accounts/hooks/__tests__/useAccountTypeLabel.spec.ts @@ -14,7 +14,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { renderHook } from '@testing-library/react' import { useAccountTypeLabel } from '../useAccountTypeLabel' import type { - AccountLogicalType, MultiSigAccount, RekeyTransition, WalletAccount, @@ -33,7 +32,7 @@ vi.mock('@hooks/useLanguage', () => ({ }), })) -const mockUseAccountLogicalType = vi.fn<() => AccountLogicalType | null>() +const mockUseCanSignWith = vi.fn<() => boolean>() const mockUseRekeyTransition = vi.fn<() => RekeyTransition | null>() vi.mock('@perawallet/wallet-core-accounts', async importOriginal => { const actual = @@ -42,15 +41,23 @@ vi.mock('@perawallet/wallet-core-accounts', async importOriginal => { >() return { ...actual, - useAccountLogicalType: () => mockUseAccountLogicalType(), + useCanSignWith: () => mockUseCanSignWith(), useRekeyTransition: () => mockUseRekeyTransition(), } }) -const algo25Account: WalletAccount = { +const accountOfType = (type: WalletAccount['type']): WalletAccount => + ({ + type, + address: `${type.toUpperCase()}_ADDR`, + keyPairId: 'key-1', + }) as WalletAccount + +const rekeyedAccount: WalletAccount = { type: 'algo25', - address: 'ALGO25ADDR', + address: 'REKEYED_ADDR', keyPairId: 'key-1', + rekeyAddress: 'AUTH_ADDR', } const multisigAccount: MultiSigAccount = { @@ -62,7 +69,7 @@ const multisigAccount: MultiSigAccount = { describe('useAccountTypeLabel', () => { beforeEach(() => { vi.clearAllMocks() - mockUseAccountLogicalType.mockReturnValue('Algo25') + mockUseCanSignWith.mockReturnValue(true) mockUseRekeyTransition.mockReturnValue(null) }) @@ -76,14 +83,14 @@ describe('useAccountTypeLabel', () => { }) it.each([ - ['HdKey', 'account_info.type_universal_wallet'], - ['Algo25', 'account_info.type_algo25'], - ['LedgerBle', 'account_info.type_ledger'], - ['NoAuth', 'account_info.type_watch'], - ['Rekeyed', 'account_info.type_no_auth'], - ] as const)('maps %s to a plain label %s', (logicalType, expected) => { - mockUseAccountLogicalType.mockReturnValue(logicalType) - const { result } = renderHook(() => useAccountTypeLabel(algo25Account)) + ['hdWallet', 'account_info.type_universal_wallet'], + ['algo25', 'account_info.type_algo25'], + ['hardware', 'account_info.type_ledger'], + ['watch', 'account_info.type_watch'], + ] as const)('maps non-rekeyed %s account to label %s', (type, expected) => { + const { result } = renderHook(() => + useAccountTypeLabel(accountOfType(type)), + ) expect(result.current).toEqual({ label: expected, main: expected, @@ -92,7 +99,6 @@ describe('useAccountTypeLabel', () => { }) it('uses a plain shared account label for a multisig account', () => { - mockUseAccountLogicalType.mockReturnValue('Multisig') const { result } = renderHook(() => useAccountTypeLabel(multisigAccount), ) @@ -104,12 +110,12 @@ describe('useAccountTypeLabel', () => { }) it('splits the transition qualifier for a rekeyed signable account', () => { - mockUseAccountLogicalType.mockReturnValue('RekeyedAuth') + mockUseCanSignWith.mockReturnValue(true) mockUseRekeyTransition.mockReturnValue({ - from: 'Algo25', - to: 'LedgerBle', + from: 'algo25', + to: 'hardware', }) - const { result } = renderHook(() => useAccountTypeLabel(algo25Account)) + const { result } = renderHook(() => useAccountTypeLabel(rekeyedAccount)) expect(result.current).toEqual({ label: 'Rekeyed (Standard to Ledger)', main: 'Rekeyed', @@ -118,13 +124,24 @@ describe('useAccountTypeLabel', () => { }) it('falls back to a plain rekeyed label when the auth account is unknown', () => { - mockUseAccountLogicalType.mockReturnValue('RekeyedAuth') + mockUseCanSignWith.mockReturnValue(true) mockUseRekeyTransition.mockReturnValue(null) - const { result } = renderHook(() => useAccountTypeLabel(algo25Account)) + const { result } = renderHook(() => useAccountTypeLabel(rekeyedAccount)) expect(result.current).toEqual({ label: 'account_info.type_rekeyed', main: 'account_info.type_rekeyed', qualifier: null, }) }) + + it('renders the no-auth label for a rekeyed account when we cannot sign', () => { + mockUseCanSignWith.mockReturnValue(false) + mockUseRekeyTransition.mockReturnValue(null) + const { result } = renderHook(() => useAccountTypeLabel(rekeyedAccount)) + expect(result.current).toEqual({ + label: 'account_info.type_no_auth', + main: 'account_info.type_no_auth', + qualifier: null, + }) + }) }) diff --git a/apps/mobile/src/modules/accounts/hooks/useAccountTypeLabel.ts b/apps/mobile/src/modules/accounts/hooks/useAccountTypeLabel.ts index d02b4aced..5f5ba7f34 100644 --- a/apps/mobile/src/modules/accounts/hooks/useAccountTypeLabel.ts +++ b/apps/mobile/src/modules/accounts/hooks/useAccountTypeLabel.ts @@ -13,7 +13,7 @@ import { useMemo } from 'react' import { AccountTypes, - useAccountLogicalType, + useCanSignWith, useRekeyTransition, } from '@perawallet/wallet-core-accounts' import { useLanguage } from '@hooks/useLanguage' @@ -39,6 +39,12 @@ export type AccountTypeLabel = { qualifier: string | null } +const plain = (label: string): AccountTypeLabel => ({ + label, + main: label, + qualifier: null, +}) + /** * Resolves the human-readable account type label (e.g. "Ledger Account", * "Rekeyed (Standard to Ledger)"). Shared by the account info card and the @@ -48,48 +54,41 @@ export const useAccountTypeLabel = ( account: WalletAccount | null | undefined, ): AccountTypeLabel => { const { t } = useLanguage() - const isMultisig = account?.type === AccountTypes.multisig - const logicalType = - useAccountLogicalType(account?.address) ?? - (isMultisig ? 'Multisig' : 'NoAuth') + const canSign = useCanSignWith(account) const rekeyTransition = useRekeyTransition(account?.address) return useMemo(() => { - const plain = (label: string): AccountTypeLabel => ({ - label, - main: label, - qualifier: null, - }) - if (!account) return plain('') - switch (logicalType) { - case 'HdKey': + if (account.rekeyAddress) { + if (!canSign) { + return plain(t('account_info.type_no_auth')) + } + if (!rekeyTransition) { + return plain(t('account_info.type_rekeyed')) + } + const { labelKey, fromKey, toKey } = + getRekeyLabelI18n(rekeyTransition) + const label = t(labelKey, { + from: t(fromKey), + to: t(toKey), + }) + return { label, ...splitAccountTypeLabel(label) } + } + + switch (account.type) { + case AccountTypes.hdWallet: return plain(t('account_info.type_universal_wallet')) - case 'Algo25': + case AccountTypes.algo25: return plain(t('account_info.type_algo25')) - case 'LedgerBle': + case AccountTypes.hardware: return plain(t('account_info.type_ledger')) - case 'Multisig': + case AccountTypes.multisig: return plain(t('account_info.type_multisig')) - case 'NoAuth': + case AccountTypes.watch: return plain(t('account_info.type_watch')) - case 'Rekeyed': - return plain(t('account_info.type_no_auth')) - case 'RekeyedAuth': { - if (!rekeyTransition) { - return plain(t('account_info.type_rekeyed')) - } - const { labelKey, fromKey, toKey } = - getRekeyLabelI18n(rekeyTransition) - const label = t(labelKey, { - from: t(fromKey), - to: t(toKey), - }) - return { label, ...splitAccountTypeLabel(label) } - } default: return plain(t('account_info.type_unknown')) } - }, [account, logicalType, rekeyTransition, t]) + }, [account, canSign, rekeyTransition, t]) } diff --git a/apps/mobile/src/modules/accounts/utils/__tests__/rekeyLabels.spec.ts b/apps/mobile/src/modules/accounts/utils/__tests__/rekeyLabels.spec.ts index 2243f6eab..fb0050cf2 100644 --- a/apps/mobile/src/modules/accounts/utils/__tests__/rekeyLabels.spec.ts +++ b/apps/mobile/src/modules/accounts/utils/__tests__/rekeyLabels.spec.ts @@ -22,7 +22,7 @@ import { getRekeyLabelI18n, splitAccountTypeLabel } from '../rekeyLabels' describe('getRekeyLabelI18n', () => { it('maps a standard-to-ledger rekey to standard/ledger parts and ledger description', () => { - expect(getRekeyLabelI18n({ from: 'Algo25', to: 'LedgerBle' })).toEqual({ + expect(getRekeyLabelI18n({ from: 'algo25', to: 'hardware' })).toEqual({ labelKey: 'account_info.type_rekeyed_transition', fromKey: 'account_info.rekey_part_standard', toKey: 'account_info.rekey_part_ledger', @@ -31,19 +31,19 @@ describe('getRekeyLabelI18n', () => { }) it('maps a ledger-to-ledger rekey to the ledger-to-ledger description', () => { - expect( - getRekeyLabelI18n({ from: 'LedgerBle', to: 'LedgerBle' }), - ).toEqual({ - labelKey: 'account_info.type_rekeyed_transition', - fromKey: 'account_info.rekey_part_ledger', - toKey: 'account_info.rekey_part_ledger', - descriptionKey: - 'account_type_info.rekeyed_ledger_to_ledger_description', - }) + expect(getRekeyLabelI18n({ from: 'hardware', to: 'hardware' })).toEqual( + { + labelKey: 'account_info.type_rekeyed_transition', + fromKey: 'account_info.rekey_part_ledger', + toKey: 'account_info.rekey_part_ledger', + descriptionKey: + 'account_type_info.rekeyed_ledger_to_ledger_description', + }, + ) }) it('maps a rekey to a standard auth account to the standard description', () => { - expect(getRekeyLabelI18n({ from: 'LedgerBle', to: 'Algo25' })).toEqual({ + expect(getRekeyLabelI18n({ from: 'hardware', to: 'algo25' })).toEqual({ labelKey: 'account_info.type_rekeyed_transition', fromKey: 'account_info.rekey_part_ledger', toKey: 'account_info.rekey_part_standard', @@ -52,7 +52,7 @@ describe('getRekeyLabelI18n', () => { }) it('maps multisig and watch base types to their parts', () => { - expect(getRekeyLabelI18n({ from: 'Multisig', to: 'NoAuth' })).toEqual({ + expect(getRekeyLabelI18n({ from: 'multisig', to: 'watch' })).toEqual({ labelKey: 'account_info.type_rekeyed_transition', fromKey: 'account_info.rekey_part_shared', toKey: 'account_info.rekey_part_watch', diff --git a/apps/mobile/src/modules/accounts/utils/rekeyLabels.ts b/apps/mobile/src/modules/accounts/utils/rekeyLabels.ts index 96f9424a0..a9587ab28 100644 --- a/apps/mobile/src/modules/accounts/utils/rekeyLabels.ts +++ b/apps/mobile/src/modules/accounts/utils/rekeyLabels.ts @@ -10,21 +10,19 @@ limitations under the License */ -import { AccountLogicalTypes } from '@perawallet/wallet-core-accounts' +import { AccountTypes } from '@perawallet/wallet-core-accounts' import type { - AccountLogicalType, + AccountType, RekeyTransition, } from '@perawallet/wallet-core-accounts' -const PART_KEY: Record = { - [AccountLogicalTypes.Algo25]: 'account_info.rekey_part_standard', - [AccountLogicalTypes.HdKey]: 'account_info.rekey_part_standard', - [AccountLogicalTypes.LedgerBle]: 'account_info.rekey_part_ledger', - [AccountLogicalTypes.Multisig]: 'account_info.rekey_part_shared', - [AccountLogicalTypes.NoAuth]: 'account_info.rekey_part_watch', - [AccountLogicalTypes.Rekeyed]: 'account_info.rekey_part_standard', - [AccountLogicalTypes.RekeyedAuth]: 'account_info.rekey_part_standard', +const PART_KEY: Record = { + [AccountTypes.algo25]: 'account_info.rekey_part_standard', + [AccountTypes.hdWallet]: 'account_info.rekey_part_standard', + [AccountTypes.hardware]: 'account_info.rekey_part_ledger', + [AccountTypes.multisig]: 'account_info.rekey_part_shared', + [AccountTypes.watch]: 'account_info.rekey_part_watch', } export type RekeyLabelI18n = { @@ -40,13 +38,10 @@ export type RekeyLabelI18n = { const descriptionKeyFor = (transition: RekeyTransition): string => { const { from, to } = transition - if ( - from === AccountLogicalTypes.LedgerBle && - to === AccountLogicalTypes.LedgerBle - ) { + if (from === AccountTypes.hardware && to === AccountTypes.hardware) { return 'account_type_info.rekeyed_ledger_to_ledger_description' } - if (to === AccountLogicalTypes.LedgerBle) { + if (to === AccountTypes.hardware) { return 'account_type_info.rekeyed_ledger_description' } return 'account_type_info.rekeyed_standard_description' diff --git a/apps/mobile/src/modules/assets/components/holdings/AssetActionButtons/AssetActionButtons.tsx b/apps/mobile/src/modules/assets/components/holdings/AssetActionButtons/AssetActionButtons.tsx index 850f6b7b8..4cefed2df 100644 --- a/apps/mobile/src/modules/assets/components/holdings/AssetActionButtons/AssetActionButtons.tsx +++ b/apps/mobile/src/modules/assets/components/holdings/AssetActionButtons/AssetActionButtons.tsx @@ -23,8 +23,7 @@ import { ReceiveFundsContent } from '@modules/transactions/components/receive-fu import { useBottomSheet } from '@modules/bottom-sheet' import { useSelectedAccount, - useAccountLogicalType, - isSigningLogicalType, + useCanSignWith, AssetWithAccountBalance, } from '@perawallet/wallet-core-accounts' import { useSendFunds } from '@modules/transactions/hooks' @@ -46,11 +45,10 @@ export const AssetActionButtons = ({ const { t } = useLanguage() const account = useSelectedAccount() const { request: requestBottomSheet } = useBottomSheet() - const logicalType = useAccountLogicalType(account?.address) + const isWatch = !useCanSignWith(account) const { setSelectedAssetId, setCanSelectAsset } = useSendFunds() const { copyToClipboard } = useClipboard() const { showToast } = useToast() - const isWatch = !logicalType || !isSigningLogicalType(logicalType) const openReceiveFunds = useCallback(() => { void requestBottomSheet({ diff --git a/apps/mobile/src/modules/assets/screens/CollectibleDetailScreen/useCollectibleDetail.tsx b/apps/mobile/src/modules/assets/screens/CollectibleDetailScreen/useCollectibleDetail.tsx index 1d171d85d..b2cee65fa 100644 --- a/apps/mobile/src/modules/assets/screens/CollectibleDetailScreen/useCollectibleDetail.tsx +++ b/apps/mobile/src/modules/assets/screens/CollectibleDetailScreen/useCollectibleDetail.tsx @@ -22,9 +22,8 @@ import { } from '@perawallet/wallet-core-assets' import { useSelectedAccount, - useAccountLogicalType, + useCanSignWith, useAccountAssetBalanceQuery, - isSigningLogicalType, type AssetWithAccountBalance, } from '@perawallet/wallet-core-accounts' import { UserRejectedSigningError } from '@perawallet/wallet-core-signing' @@ -85,7 +84,7 @@ export const useCollectibleDetail = ( ) const account = useSelectedAccount() const { network } = useNetwork() - const logicalType = useAccountLogicalType(account?.address) + const isWatch = !useCanSignWith(account) const { t } = useLanguage() const { data: assetBalance } = useAccountAssetBalanceQuery( account ?? undefined, @@ -98,7 +97,6 @@ export const useCollectibleDetail = ( const navigation = useNavigation() const collectible = asset?.peraMetadata?.collectible - const isWatch = !logicalType || !isSigningLogicalType(logicalType) const traits = collectible?.traits ?? [] const media = collectible?.media ?? [] diff --git a/apps/mobile/src/modules/rekey/components/RekeySummaryRow/RekeySummaryRow.tsx b/apps/mobile/src/modules/rekey/components/RekeySummaryRow/RekeySummaryRow.tsx index ff05a62c3..4c50dc2ed 100644 --- a/apps/mobile/src/modules/rekey/components/RekeySummaryRow/RekeySummaryRow.tsx +++ b/apps/mobile/src/modules/rekey/components/RekeySummaryRow/RekeySummaryRow.tsx @@ -15,24 +15,21 @@ import { PWText, PWView } from '@components/core' import { AccountIcon } from '@modules/accounts/components/AccountIcon' import { useStyles } from './styles' -import type { - AccountLogicalType, - WalletAccount, -} from '@perawallet/wallet-core-accounts' +import type { WalletAccount } from '@perawallet/wallet-core-accounts' export type RekeySummaryRowProps = { account: WalletAccount | null /** - * Project a different logical type than the one currently stored. Used by - * the undo-rekey "after" row so the source renders as its base type - * (HdKey / Algo25 / LedgerBle) instead of its current Rekeyed glyph. + * Render the icon for the account's base type, ignoring its rekey state. + * Used by the undo-rekey "after" row to preview what the source will look + * like once the rekey is undone. */ - logicalTypeOverride?: AccountLogicalType + ignoreRekey?: boolean } export const RekeySummaryRow = ({ account, - logicalTypeOverride, + ignoreRekey, }: RekeySummaryRowProps) => { const styles = useStyles() @@ -45,7 +42,7 @@ export const RekeySummaryRow = ({ { diff --git a/apps/mobile/src/modules/rekey/screens/undo-rekey/UndoRekeyConfirmScreen/__tests__/useUndoRekeyConfirmScreen.spec.ts b/apps/mobile/src/modules/rekey/screens/undo-rekey/UndoRekeyConfirmScreen/__tests__/useUndoRekeyConfirmScreen.spec.ts index 4adf0443f..babe517a9 100644 --- a/apps/mobile/src/modules/rekey/screens/undo-rekey/UndoRekeyConfirmScreen/__tests__/useUndoRekeyConfirmScreen.spec.ts +++ b/apps/mobile/src/modules/rekey/screens/undo-rekey/UndoRekeyConfirmScreen/__tests__/useUndoRekeyConfirmScreen.spec.ts @@ -60,11 +60,10 @@ vi.mock('@modules/webview', () => ({ const mockSourceAccount = { address: 'SRC', name: 'Source', + type: 'algo25' as 'algo25' | 'watch', rekeyAddress: 'AUTH' as string | undefined, } -const mockAuthAccount = { address: 'AUTH', name: 'Auth' } - -let mockBaseType = 'Algo25' +const mockAuthAccount = { address: 'AUTH', name: 'Auth', type: 'algo25' } vi.mock('@perawallet/wallet-core-accounts', async importOriginal => { const actual = @@ -78,7 +77,6 @@ vi.mock('@perawallet/wallet-core-accounts', async importOriginal => { if (address === 'AUTH') return mockAuthAccount return undefined }, - baseTypeFor: () => mockBaseType, } }) @@ -111,7 +109,7 @@ describe('useUndoRekeyConfirmScreen', () => { beforeEach(() => { vi.clearAllMocks() mockSourceAccount.rekeyAddress = 'AUTH' - mockBaseType = 'Algo25' + mockSourceAccount.type = 'algo25' mockSubmitAsync.mockReset() mockRequestBottomSheet.mockReset() }) @@ -182,7 +180,7 @@ describe('useUndoRekeyConfirmScreen', () => { }) it('uses the destructive no-auth warning variant when the source will become no-auth', async () => { - mockBaseType = 'NoAuth' + mockSourceAccount.type = 'watch' mockRequestBottomSheet.mockReturnValueOnce(new Promise(() => {})) const { result } = renderHook(() => useUndoRekeyConfirmScreen()) diff --git a/apps/mobile/src/modules/rekey/screens/undo-rekey/UndoRekeyConfirmScreen/useUndoRekeyConfirmScreen.tsx b/apps/mobile/src/modules/rekey/screens/undo-rekey/UndoRekeyConfirmScreen/useUndoRekeyConfirmScreen.tsx index 6cb5ef6e7..5aafd07fa 100644 --- a/apps/mobile/src/modules/rekey/screens/undo-rekey/UndoRekeyConfirmScreen/useUndoRekeyConfirmScreen.tsx +++ b/apps/mobile/src/modules/rekey/screens/undo-rekey/UndoRekeyConfirmScreen/useUndoRekeyConfirmScreen.tsx @@ -13,8 +13,7 @@ import { useCallback } from 'react' import { useRoute } from '@react-navigation/native' import { - AccountLogicalTypes, - baseTypeFor, + AccountTypes, getAccountDisplayName, useFindAccountByAddress, } from '@perawallet/wallet-core-accounts' @@ -116,8 +115,7 @@ export const useUndoRekeyConfirmScreen = const currentAuthName = currentAuth ? getAccountDisplayName(currentAuth) : '' - const willBecomeNoAuth = - baseTypeFor(source) === AccountLogicalTypes.NoAuth + const willBecomeNoAuth = source.type === AccountTypes.watch const i18nPrefix = willBecomeNoAuth ? 'rekey.undo.no_auth_warning' : 'rekey.undo.warning' diff --git a/apps/mobile/src/modules/swap/screens/SwapScreen/SwapScreen.tsx b/apps/mobile/src/modules/swap/screens/SwapScreen/SwapScreen.tsx index 5c9ff2b8d..1c3061dc7 100644 --- a/apps/mobile/src/modules/swap/screens/SwapScreen/SwapScreen.tsx +++ b/apps/mobile/src/modules/swap/screens/SwapScreen/SwapScreen.tsx @@ -15,7 +15,7 @@ import { useLanguage } from '@hooks/useLanguage' import { config } from '@perawallet/wallet-core-config' import { PWIcon, PWText, PWToolbar, PWView } from '@components/core' import { - isSigningAccount, + canSignWith, useAllAccounts, WalletAccount, } from '@perawallet/wallet-core-accounts' @@ -32,7 +32,7 @@ export const SwapScreen = () => { const styles = useStyles() const accounts = useAllAccounts() const swapAccountFilter = useCallback( - (account: WalletAccount) => isSigningAccount(account, accounts), + (account: WalletAccount) => canSignWith(account, accounts), [accounts], ) const { pushWebView } = useWebView() diff --git a/apps/mobile/src/modules/transactions/screens/send-funds/SelectDestinationScreen/useSelectDestinationScreen.ts b/apps/mobile/src/modules/transactions/screens/send-funds/SelectDestinationScreen/useSelectDestinationScreen.ts index 2b56d5c6c..a68466a38 100644 --- a/apps/mobile/src/modules/transactions/screens/send-funds/SelectDestinationScreen/useSelectDestinationScreen.ts +++ b/apps/mobile/src/modules/transactions/screens/send-funds/SelectDestinationScreen/useSelectDestinationScreen.ts @@ -13,9 +13,8 @@ import { useSendFunds } from '@modules/transactions/hooks' import { SendFundsStackParamList } from '@modules/transactions/routes/send-funds' import { - isSigningLogicalType, + canSignWith, useAccountBalancesQuery, - useAllAccountLogicalTypes, useAllAccounts, useSelectedAccount, } from '@perawallet/wallet-core-accounts' @@ -28,7 +27,6 @@ export const useSelectDestinationScreen = () => { const { selectedAssetId, setDestination, setSendMode } = useSendFunds() const selectedAccount = useSelectedAccount() const accounts = useAllAccounts() - const logicalTypes = useAllAccountLogicalTypes() const { accountBalances } = useAccountBalancesQuery(accounts) const assetIDs = useMemo( @@ -72,9 +70,9 @@ export const useSelectDestinationScreen = () => { } // Check if receiver is a local account we can sign for - const localType = logicalTypes.get(address) + const receiver = accounts.find(a => a.address === address) const isLocalSignable = - !!localType && isSigningLogicalType(localType) + !!receiver && canSignWith(receiver, accounts) if (isLocalSignable) { // Express send: local account, we handle opt-in + transfer @@ -88,7 +86,7 @@ export const useSelectDestinationScreen = () => { }, [ selectedAsset, - logicalTypes, + accounts, accountBalances, setSendMode, setDestination, diff --git a/apps/mobile/src/modules/webview/hooks/__tests__/usePeraWebviewInterface.test.tsx b/apps/mobile/src/modules/webview/hooks/__tests__/usePeraWebviewInterface.test.tsx index a1033f40e..6091d572a 100644 --- a/apps/mobile/src/modules/webview/hooks/__tests__/usePeraWebviewInterface.test.tsx +++ b/apps/mobile/src/modules/webview/hooks/__tests__/usePeraWebviewInterface.test.tsx @@ -90,17 +90,32 @@ vi.mock('@perawallet/wallet-core-blockchain', () => ({ })) vi.mock('@perawallet/wallet-core-accounts', () => ({ - isHDWalletAccount: vi.fn(account => account.type === 'standard'), + AccountTypes: { + algo25: 'algo25', + hdWallet: 'hdWallet', + hardware: 'hardware', + multisig: 'multisig', + watch: 'watch', + }, + isHDWalletAccount: vi.fn(account => account.type === 'hdWallet'), isRekeyedAccount: vi.fn(() => false), + canSignWith: vi.fn(() => true), useSigningAccounts: vi.fn(() => [ { address: 'addr1', name: 'Account 1', - type: 'standard', + type: 'hdWallet', + hdWalletDetails: { hdWalletAddress: 'addr1' }, + }, + ]), + useAllAccounts: vi.fn(() => [ + { + address: 'addr1', + name: 'Account 1', + type: 'hdWallet', hdWalletDetails: { hdWalletAddress: 'addr1' }, }, ]), - useAllAccountLogicalTypes: vi.fn(() => new Map([['addr1', 'HdKey']])), useSelectedAccountAddress: vi.fn(() => ({ setSelectedAccountAddress: vi.fn(), })), @@ -436,27 +451,37 @@ describe('usePeraWebviewInterface', () => { }) describe('getAddresses payload (Android parity)', () => { - // useSigningAccounts owns the Watch/NoAuth filtering — the bridge just - // maps. These tests pin the mapping (name fallback, order preservation) - // and assume the filter behavior is covered by the package's own tests. + // useSigningAccounts owns the Watch/Unsignable filtering — the bridge + // just maps. These tests pin the mapping (name fallback, order + // preservation) and assume the filter behavior is covered by the + // package's own tests. const setupAccountsMock = async (config: { - accounts: Array<{ address: string; name?: string; type: string }> - types: Map + accounts: Array<{ + address: string + name?: string + type: string + rekeyAddress?: string + }> + signableAddresses?: Set + signers?: Set }) => { const accounts = await import('@perawallet/wallet-core-accounts') - const signingAddresses = new Set( - [...config.types.entries()] - .filter(([, t]) => t !== 'NoAuth' && t !== 'Watch') - .map(([addr]) => addr), - ) + const signers = config.signers ?? new Set(config.signableAddresses) vi.mocked(accounts.useSigningAccounts).mockReturnValue( config.accounts.filter(a => - signingAddresses.has(a.address), + signers.has(a.address), ) as unknown as ReturnType, ) - vi.mocked(accounts.useAllAccountLogicalTypes).mockReturnValue( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - config.types as any, + vi.mocked(accounts.useAllAccounts).mockReturnValue( + config.accounts as unknown as ReturnType< + typeof accounts.useAllAccounts + >, + ) + vi.mocked(accounts.canSignWith).mockImplementation( + (account: unknown) => { + const a = account as { address: string } + return signers.has(a.address) + }, ) } @@ -476,18 +501,18 @@ describe('usePeraWebviewInterface', () => { return JSON.parse(match[1]) } - it('drops non-signing accounts (Watch, NoAuth)', async () => { + it('drops non-signing accounts (Watch, Unsignable)', async () => { await setupAccountsMock({ accounts: [ - { address: 'signer', name: 'Signer', type: 'standard' }, - { address: 'watch', name: 'Watch', type: 'standard' }, - { address: 'noauth', name: 'NoAuth', type: 'standard' }, + { address: 'signer', name: 'Signer', type: 'hdWallet' }, + { address: 'watch', name: 'Watch', type: 'watch' }, + { + address: 'unsignable', + name: 'Unsignable', + type: 'watch', + }, ], - types: new Map([ - ['signer', 'HdKey'], - ['watch', 'NoAuth'], - ['noauth', 'NoAuth'], - ]), + signers: new Set(['signer']), }) const { result } = renderHook(() => @@ -511,15 +536,11 @@ describe('usePeraWebviewInterface', () => { it('preserves store order — ordering is the consumer-side concern', async () => { await setupAccountsMock({ accounts: [ - { address: 'first', name: 'First', type: 'standard' }, - { address: 'second', name: 'Second', type: 'standard' }, - { address: 'third', name: 'Third', type: 'standard' }, + { address: 'first', name: 'First', type: 'hdWallet' }, + { address: 'second', name: 'Second', type: 'hdWallet' }, + { address: 'third', name: 'Third', type: 'hdWallet' }, ], - types: new Map([ - ['first', 'HdKey'], - ['second', 'HdKey'], - ['third', 'HdKey'], - ]), + signers: new Set(['first', 'second', 'third']), }) const { result } = renderHook(() => @@ -545,8 +566,8 @@ describe('usePeraWebviewInterface', () => { it('sends empty name string when account has no name', async () => { await setupAccountsMock({ - accounts: [{ address: 'nameless', type: 'standard' }], - types: new Map([['nameless', 'HdKey']]), + accounts: [{ address: 'nameless', type: 'hdWallet' }], + signers: new Set(['nameless']), }) const { result } = renderHook(() => @@ -564,7 +585,7 @@ describe('usePeraWebviewInterface', () => { const payload = getPayload() expect(payload).toEqual([ - { name: '', address: 'nameless', type: 'HdKey' }, + { name: '', address: 'nameless', type: 'HDWallet' }, ]) }) }) diff --git a/apps/mobile/src/modules/webview/hooks/usePeraWebviewInterface.ts b/apps/mobile/src/modules/webview/hooks/usePeraWebviewInterface.ts index 718bd8a44..e4a085c6d 100644 --- a/apps/mobile/src/modules/webview/hooks/usePeraWebviewInterface.ts +++ b/apps/mobile/src/modules/webview/hooks/usePeraWebviewInterface.ts @@ -22,8 +22,11 @@ import { useTransactionEncoder, } from '@perawallet/wallet-core-blockchain' import { - useAllAccountLogicalTypes, + AccountTypes, + canSignWith, + useAllAccounts, useSigningAccounts, + type WalletAccount, } from '@perawallet/wallet-core-accounts' import { useCurrency } from '@perawallet/wallet-core-currencies' import { useCallback } from 'react' @@ -64,6 +67,51 @@ type WebviewMessage = { params?: Record } +/** + * Type identifiers we expose to dApps over the webview bridge. The Pera SDK + * currently accepts the legacy names (`HdKey`, `LedgerBle`, `NoAuth`, + * `Rekeyed`, `RekeyedAuth`, `Joint`); the webapp side is being updated to the + * names below in lockstep with this change. + */ +type WebviewAccountType = + | 'Algo25' + | 'HDWallet' + | 'Hardware' + | 'Multisig' + | 'Unsignable' + | 'RekeyedSignable' + | 'RekeyedUnsignable' + +const BASE_WEBVIEW_TYPE: Record< + WalletAccount['type'], + Exclude +> = { + [AccountTypes.algo25]: 'Algo25', + [AccountTypes.hdWallet]: 'HDWallet', + [AccountTypes.hardware]: 'Hardware', + [AccountTypes.multisig]: 'Multisig', + [AccountTypes.watch]: 'Unsignable', +} + +/** + * Maps an account onto the type identifier we hand to the webapp. Only + * invoked on `signingAccounts`, which has already filtered out non-signing + * accounts — `Unsignable` and `RekeyedUnsignable` won't actually be emitted + * in practice, but the full mapping is kept here so the bridge remains + * self-contained should the upstream filter ever loosen. + */ +const toWebviewAccountType = ( + account: WalletAccount, + accounts: WalletAccount[], +): WebviewAccountType => { + if (account.rekeyAddress) { + return canSignWith(account, accounts) + ? 'RekeyedSignable' + : 'RekeyedUnsignable' + } + return BASE_WEBVIEW_TYPE[account.type] +} + export const usePeraWebviewInterface = ( webview: Nullable, securedConnection: boolean, @@ -73,7 +121,7 @@ export const usePeraWebviewInterface = ( ) => { const { showToast } = useToast() const signingAccounts = useSigningAccounts() - const logicalTypes = useAllAccountLogicalTypes() + const allAccounts = useAllAccounts() const { network } = useNetwork() const deviceID = useDeviceID(network) const darkmode = useIsDarkMode() @@ -319,13 +367,13 @@ export const usePeraWebviewInterface = ( const payload = signingAccounts.map(account => ({ name: account.name ?? '', address: account.address, - type: logicalTypes.get(account.address) ?? 'NoAuth', + type: toWebviewAccountType(account, allAccounts), })) sendMessageToWebview(message.id, payload, webview) }, ) }, - [securedConnection, sourceUrl, signingAccounts, logicalTypes, webview], + [securedConnection, sourceUrl, signingAccounts, allAccounts, webview], ) const getSettings = useCallback( diff --git a/apps/mobile/vitest.setup.ts b/apps/mobile/vitest.setup.ts index 307db0c56..cd8e54dc7 100644 --- a/apps/mobile/vitest.setup.ts +++ b/apps/mobile/vitest.setup.ts @@ -2273,7 +2273,15 @@ vi.mock('@perawallet/wallet-core-accounts', () => { (account: any) => account?.type === 'multisig', ), hasSigningKeys: vi.fn((account: any) => !!account?.keyPairId), - canSignWithAccount: vi.fn((account: any) => !!account?.keyPairId), + canSignWith: vi.fn((account: any) => !!account?.keyPairId), + getRekeyAccount: vi.fn(() => null), + getSignerFor: vi.fn( + (address: string, accs: any[] = []) => + accs.find((a: any) => a.address === address) ?? null, + ), + useCanSignWith: vi.fn((account: any) => !!account?.keyPairId), + useRekeyAccount: vi.fn(() => null), + useSignerFor: vi.fn(() => null), useAccountAssetBalanceQuery: vi.fn(() => ({ data: null, isPending: false, @@ -2293,15 +2301,6 @@ vi.mock('@perawallet/wallet-core-accounts', () => { multisig: 'multisig', watch: 'watch', }, - AccountLogicalTypes: { - Algo25: 'Algo25', - HdKey: 'HdKey', - LedgerBle: 'LedgerBle', - Multisig: 'Multisig', - Rekeyed: 'Rekeyed', - RekeyedAuth: 'RekeyedAuth', - NoAuth: 'NoAuth', - }, useOwnedAssets: vi.fn(() => ({ assets: [], isLoading: false, diff --git a/packages/accounts/src/__tests__/logical-type.spec.ts b/packages/accounts/src/__tests__/logical-type.spec.ts deleted file mode 100644 index 6ca3b5e9c..000000000 --- a/packages/accounts/src/__tests__/logical-type.spec.ts +++ /dev/null @@ -1,248 +0,0 @@ -/* - Copyright 2022-2025 Pera Wallet, LDA - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License - */ - -import { describe, expect, it } from 'vitest' -import { - AccountLogicalTypes, - deriveAccountLogicalType, - rekeyTransitionFor, -} from '../logical-type' -import { - AccountTypes, - type Algo25Account, - type HDWalletAccount, - type HardwareWalletAccount, - type MultiSigAccount, - type WatchAccount, -} from '../models' - -const algo25 = (address: string, keyPairId = 'kp'): Algo25Account => ({ - type: AccountTypes.algo25, - address, - keyPairId, -}) - -const hdWallet = (address: string): HDWalletAccount => ({ - type: AccountTypes.hdWallet, - address, - keyPairId: 'kp', - hdWalletDetails: { - account: 0, - change: 0, - keyIndex: 0, - derivationType: 32, - }, -}) - -const ledger = (address: string): HardwareWalletAccount => ({ - type: AccountTypes.hardware, - address, - hardwareDetails: { - manufacturer: 'ledger', - deviceId: 'd', - deviceName: 'Ledger', - accountIndex: 0, - transportType: 'ble', - }, -}) - -const multisig = (address: string): MultiSigAccount => ({ - type: AccountTypes.multisig, - address, - multisigDetails: { threshold: 1, addresses: [] }, -}) - -const watch = (address: string, rekeyAddress?: string): WatchAccount => ({ - type: AccountTypes.watch, - address, - rekeyAddress, -}) - -describe('deriveAccountLogicalType', () => { - it('returns Algo25 for a standard account with no rekey', () => { - const a = algo25('A') - expect(deriveAccountLogicalType(a, [a])).toBe( - AccountLogicalTypes.Algo25, - ) - }) - - it('returns HdKey for an HD wallet account', () => { - const a = hdWallet('A') - expect(deriveAccountLogicalType(a, [a])).toBe(AccountLogicalTypes.HdKey) - }) - - it('returns LedgerBle for any hardware account', () => { - const a = ledger('A') - expect(deriveAccountLogicalType(a, [a])).toBe( - AccountLogicalTypes.LedgerBle, - ) - }) - - it('returns Multisig for multisig accounts', () => { - const a = multisig('A') - expect(deriveAccountLogicalType(a, [a])).toBe( - AccountLogicalTypes.Multisig, - ) - }) - - it('returns NoAuth for a watch account with no rekey', () => { - const a = watch('A') - expect(deriveAccountLogicalType(a, [a])).toBe( - AccountLogicalTypes.NoAuth, - ) - }) - - it('returns RekeyedAuth when rekey target exists and can sign', () => { - const signer = algo25('S') - const rekeyed = watch('A', 'S') - expect(deriveAccountLogicalType(rekeyed, [rekeyed, signer])).toBe( - AccountLogicalTypes.RekeyedAuth, - ) - }) - - it('returns RekeyedAuth for an Algo25 account rekeyed to a signer we hold', () => { - const signer = algo25('S') - const original: Algo25Account = { ...algo25('A'), rekeyAddress: 'S' } - expect(deriveAccountLogicalType(original, [original, signer])).toBe( - AccountLogicalTypes.RekeyedAuth, - ) - }) - - it('returns Rekeyed when original was signable but auth is not in the wallet', () => { - const original: Algo25Account = { ...algo25('A'), rekeyAddress: 'S' } - expect(deriveAccountLogicalType(original, [original])).toBe( - AccountLogicalTypes.Rekeyed, - ) - }) - - it('returns Rekeyed when a watch account is rekeyed and auth is not in the wallet', () => { - const a = watch('A', 'S') - expect(deriveAccountLogicalType(a, [a])).toBe( - AccountLogicalTypes.Rekeyed, - ) - }) - - it('returns Rekeyed when auth account is in the wallet but cannot sign (watch → watch)', () => { - const authWatch = watch('S') - const rekeyed = watch('A', 'S') - expect(deriveAccountLogicalType(rekeyed, [rekeyed, authWatch])).toBe( - AccountLogicalTypes.Rekeyed, - ) - }) - - it('returns RekeyedAuth when a shared account is rekeyed to a multisig with a local signable participant', () => { - // A shared account can only be rekeyed to another shared account; it - // is signable when the wallet holds at least one signable participant - // of the auth multisig — multisig signing is propose-based. - const participant = algo25('P1') - const ms: MultiSigAccount = { - ...multisig('M'), - multisigDetails: { threshold: 2, addresses: ['P1', 'P2'] }, - } - const rekeyed: MultiSigAccount = { - ...multisig('A'), - rekeyAddress: 'M', - } - expect( - deriveAccountLogicalType(rekeyed, [rekeyed, ms, participant]), - ).toBe(AccountLogicalTypes.RekeyedAuth) - }) - - it('returns Rekeyed when rekeyed to a multisig with no local signable participant', () => { - const ms: MultiSigAccount = { - ...multisig('M'), - multisigDetails: { threshold: 2, addresses: ['P1', 'P2'] }, - } - const rekeyed: MultiSigAccount = { - ...multisig('A'), - rekeyAddress: 'M', - } - expect(deriveAccountLogicalType(rekeyed, [rekeyed, ms])).toBe( - AccountLogicalTypes.Rekeyed, - ) - }) - - it('counts a rekeyed participant as signable — multisig participants sign with their own key', () => { - // P1 is itself rekeyed away, but a multisig slot is bound to the - // participant's own pubkey, so P1 still signs and still counts. - const participant: Algo25Account = { - ...algo25('P1'), - rekeyAddress: 'ELSEWHERE', - } - const ms: MultiSigAccount = { - ...multisig('M'), - multisigDetails: { threshold: 2, addresses: ['P1', 'P2'] }, - } - const rekeyed: MultiSigAccount = { - ...multisig('A'), - rekeyAddress: 'M', - } - expect( - deriveAccountLogicalType(rekeyed, [rekeyed, ms, participant]), - ).toBe(AccountLogicalTypes.RekeyedAuth) - }) - - it('does not follow the auth account’s own rekey to find a signer (single hop)', () => { - // A -> B, B -> A, neither holds a key. Single-hop classification - // only consults B's own key, so A is Rekeyed (not signable). - const a: Algo25Account = { ...algo25('A'), rekeyAddress: 'B' } - const b: Algo25Account = { ...algo25('B'), rekeyAddress: 'A' } - const aNoKey = { ...a, keyPairId: '' } as Algo25Account - const bNoKey = { ...b, keyPairId: '' } as Algo25Account - expect(deriveAccountLogicalType(aNoKey, [aNoKey, bNoKey])).toBe( - AccountLogicalTypes.Rekeyed, - ) - }) -}) - -describe('rekeyTransitionFor', () => { - it('returns null for a non-rekeyed account', () => { - const a = algo25('A') - expect(rekeyTransitionFor(a, [a])).toBeNull() - }) - - it('returns null for a Rekeyed account whose auth is not in the wallet', () => { - const original: Algo25Account = { ...algo25('A'), rekeyAddress: 'S' } - expect(rekeyTransitionFor(original, [original])).toBeNull() - }) - - it('returns from/to base types for a standard account rekeyed to a ledger', () => { - const signer = ledger('S') - const original: Algo25Account = { ...algo25('A'), rekeyAddress: 'S' } - expect(rekeyTransitionFor(original, [original, signer])).toEqual({ - from: AccountLogicalTypes.Algo25, - to: AccountLogicalTypes.LedgerBle, - }) - }) - - it('returns from/to base types for a ledger account rekeyed to a standard account', () => { - const signer = algo25('S') - const original: HardwareWalletAccount = { - ...ledger('A'), - rekeyAddress: 'S', - } - expect(rekeyTransitionFor(original, [original, signer])).toEqual({ - from: AccountLogicalTypes.LedgerBle, - to: AccountLogicalTypes.Algo25, - }) - }) - - it('reports the immediate auth account for a multi-hop chain', () => { - const signer = algo25('S') - const mid: Algo25Account = { ...algo25('B'), rekeyAddress: 'S' } - const original: Algo25Account = { ...algo25('A'), rekeyAddress: 'B' } - expect(rekeyTransitionFor(original, [original, mid, signer])).toEqual({ - from: AccountLogicalTypes.Algo25, - to: AccountLogicalTypes.Algo25, - }) - }) -}) diff --git a/packages/accounts/src/__tests__/signing.test.ts b/packages/accounts/src/__tests__/signing.test.ts new file mode 100644 index 000000000..0bc47cae8 --- /dev/null +++ b/packages/accounts/src/__tests__/signing.test.ts @@ -0,0 +1,326 @@ +/* + 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, expect, it } from 'vitest' +import { + canSignWith, + getRekeyAccount, + getSignerFor, + rekeyTransitionFor, +} from '../utils' +import { + AccountTypes, + type Algo25Account, + type HDWalletAccount, + type HardwareWalletAccount, + type MultiSigAccount, + type WatchAccount, + type WalletAccount, +} from '../models' + +const algo25 = ( + address: string, + extra: Partial = {}, +): Algo25Account => ({ + type: AccountTypes.algo25, + address, + keyPairId: 'kp', + ...extra, +}) + +const hdWallet = ( + address: string, + extra: Partial = {}, +): HDWalletAccount => ({ + type: AccountTypes.hdWallet, + address, + keyPairId: 'kp', + hdWalletDetails: { + account: 0, + change: 0, + keyIndex: 0, + derivationType: 32, + }, + ...extra, +}) + +const hardware = ( + address: string, + extra: Partial = {}, +): HardwareWalletAccount => ({ + type: AccountTypes.hardware, + address, + hardwareDetails: { + manufacturer: 'ledger', + deviceId: 'd', + deviceName: 'Ledger', + accountIndex: 0, + transportType: 'ble', + }, + ...extra, +}) + +const multisig = ( + address: string, + participants: string[], + extra: Partial = {}, +): MultiSigAccount => ({ + type: AccountTypes.multisig, + address, + multisigDetails: { threshold: 2, addresses: participants }, + ...extra, +}) + +const watch = (address: string, rekeyAddress?: string): WatchAccount => ({ + type: AccountTypes.watch, + address, + rekeyAddress, +}) + +describe('getRekeyAccount', () => { + it('returns null when the address is not in the wallet', () => { + expect(getRekeyAccount('Z', [algo25('A')])).toBeNull() + }) + + it('returns null when the account has no rekey', () => { + const a = algo25('A') + expect(getRekeyAccount('A', [a])).toBeNull() + }) + + it('returns the auth account when the target is held', () => { + const auth = algo25('AUTH') + const a = algo25('A', { rekeyAddress: 'AUTH' }) + expect(getRekeyAccount('A', [a, auth])).toBe(auth) + }) + + it('returns null when the auth target is unknown locally', () => { + const a = algo25('A', { rekeyAddress: 'MISSING' }) + expect(getRekeyAccount('A', [a])).toBeNull() + }) + + it('reports the immediate auth — does not follow chains', () => { + const mid = algo25('B', { rekeyAddress: 'C' }) + const a = algo25('A', { rekeyAddress: 'B' }) + const c = algo25('C') + expect(getRekeyAccount('A', [a, mid, c])).toBe(mid) + }) +}) + +describe('getSignerFor', () => { + it('returns null for an unknown address', () => { + expect(getSignerFor('UNKNOWN', [])).toBeNull() + }) + + it('returns the account itself for a standard signing account', () => { + const a = algo25('A') + expect(getSignerFor('A', [a])).toBe(a) + }) + + it('returns the account itself for an HD wallet account', () => { + const a = hdWallet('A') + expect(getSignerFor('A', [a])).toBe(a) + }) + + it('returns the account itself for a hardware account (no keyPairId)', () => { + const a = hardware('A') + expect(getSignerFor('A', [a])).toBe(a) + }) + + it('returns null for a non-rekeyed watch account', () => { + const a = watch('A') + expect(getSignerFor('A', [a])).toBeNull() + }) + + it('returns the auth account when rekeyed to a signable algo25', () => { + const auth = algo25('S') + const a = watch('A', 'S') + expect(getSignerFor('A', [a, auth])).toBe(auth) + }) + + it('returns the auth account when rekeyed to a hardware account', () => { + const auth = hardware('S') + const a = watch('A', 'S') + expect(getSignerFor('A', [a, auth])).toBe(auth) + }) + + it('returns the auth multisig when rekeyed to a signable multisig', () => { + const participant = algo25('P1') + const ms = multisig('M', ['P1', 'P2']) + const a = watch('A', 'M') + expect(getSignerFor('A', [a, ms, participant])).toBe(ms) + }) + + it('returns null when rekeyed to a multisig with no local participants', () => { + const ms = multisig('M', ['P1', 'P2']) + const a = watch('A', 'M') + expect(getSignerFor('A', [a, ms])).toBeNull() + }) + + it('returns null when rekeyed to an account we cannot sign with', () => { + const auth = watch('S') + const a = watch('A', 'S') + expect(getSignerFor('A', [a, auth])).toBeNull() + }) + + it('returns null when the rekey target is unknown locally', () => { + const a = watch('A', 'MISSING') + expect(getSignerFor('A', [a])).toBeNull() + }) + + it('returns the multisig itself when at least one participant is local + signable', () => { + const participant = algo25('P1') + const ms = multisig('M', ['P1', 'P2']) + expect(getSignerFor('M', [ms, participant])).toBe(ms) + }) + + it('returns null for a multisig with no local participants', () => { + const ms = multisig('M', ['P1', 'P2']) + expect(getSignerFor('M', [ms])).toBeNull() + }) + + it('counts a hardware participant in a multisig', () => { + const participant = hardware('P1') + const ms = multisig('M', ['P1', 'P2']) + expect(getSignerFor('M', [ms, participant])).toBe(ms) + }) + + it('counts a rekeyed participant as signable — slots are bound to own key', () => { + // The participant being rekeyed itself doesn't matter; the multisig + // slot is bound to the participant's own pubkey. + const participant = algo25('P1', { rekeyAddress: 'ELSEWHERE' }) + const ms = multisig('M', ['P1', 'P2']) + expect(getSignerFor('M', [ms, participant])).toBe(ms) + }) + + it('is single-hop — does not chase A → B → C even if C can sign', () => { + // Both A and B hold no key. Following B's auth-addr to C is not done. + const a = watch('A', 'B') + const b = watch('B', 'C') + const c = algo25('C') + expect(getSignerFor('A', [a, b, c])).toBeNull() + }) + + it('does not infinite-loop on a cyclic auth chain (A → B → A)', () => { + const a = watch('A', 'B') + const b = watch('B', 'A') + expect(getSignerFor('A', [a, b])).toBeNull() + }) +}) + +describe('canSignWith', () => { + it('returns true for accounts holding their own key', () => { + const a = algo25('A') + expect(canSignWith(a, [a])).toBe(true) + }) + + it('returns true for hardware accounts even with no keyPairId', () => { + const a = hardware('A') + expect(canSignWith(a, [a])).toBe(true) + }) + + it('returns false for non-rekeyed watch accounts', () => { + const a = watch('A') + expect(canSignWith(a, [a])).toBe(false) + }) + + it('returns true when rekeyed to a signable auth account', () => { + const auth = algo25('S') + const a = watch('A', 'S') + expect(canSignWith(a, [a, auth])).toBe(true) + }) + + it('returns true when rekeyed to a signable multisig', () => { + const participant = algo25('P1') + const ms = multisig('M', ['P1', 'P2']) + const a = watch('A', 'M') + expect(canSignWith(a, [a, ms, participant])).toBe(true) + }) + + it('returns false when rekeyed to an unsignable multisig', () => { + const ms = multisig('M', ['P1', 'P2']) + const a = watch('A', 'M') + expect(canSignWith(a, [a, ms])).toBe(false) + }) + + it('returns false when rekeyed but the target is not held', () => { + const a = watch('A', 'MISSING') + expect(canSignWith(a, [a])).toBe(false) + }) + + it('works on an account passed in hand even when not in the accounts list', () => { + const a = algo25('A') + // `accounts` is empty — `canSignWith` should still evaluate `a` directly. + expect(canSignWith(a, [])).toBe(true) + }) + + it('returns false for a multisig with no local participants', () => { + const ms = multisig('M', ['P1', 'P2']) + expect(canSignWith(ms, [ms])).toBe(false) + }) + + it('returns true for a multisig with one local signable participant', () => { + const participant = algo25('P1') + const ms = multisig('M', ['P1', 'P2']) + expect(canSignWith(ms, [ms, participant])).toBe(true) + }) +}) + +describe('rekeyTransitionFor', () => { + it('returns null for a non-rekeyed account', () => { + const a = algo25('A') + expect(rekeyTransitionFor(a, [a])).toBeNull() + }) + + it('returns null when the auth account is missing locally', () => { + const a: WalletAccount = { ...algo25('A'), rekeyAddress: 'MISSING' } + expect(rekeyTransitionFor(a, [a])).toBeNull() + }) + + it('returns null when the rekey is unsignable', () => { + const auth = watch('S') + const a = watch('A', 'S') + expect(rekeyTransitionFor(a, [a, auth])).toBeNull() + }) + + it('returns from/to raw account types for a signable algo25 → hardware rekey', () => { + const auth = hardware('S') + const a: WalletAccount = { ...algo25('A'), rekeyAddress: 'S' } + expect(rekeyTransitionFor(a, [a, auth])).toEqual({ + from: AccountTypes.algo25, + to: AccountTypes.hardware, + }) + }) + + it('returns from/to for a multisig rekeyed to a multisig', () => { + const participant = algo25('P1') + const authMs = multisig('M', ['P1', 'P2']) + const a: MultiSigAccount = { + ...multisig('A', ['P1', 'P3']), + rekeyAddress: 'M', + } + expect(rekeyTransitionFor(a, [a, authMs, participant])).toEqual({ + from: AccountTypes.multisig, + to: AccountTypes.multisig, + }) + }) + + it('reports the immediate auth account, not the eventual root', () => { + // A → B → C; from B's perspective the auth is C. The transition is + // from algo25 to algo25, regardless of A pointing at B. + const c = algo25('C') + const b: WalletAccount = { ...algo25('B'), rekeyAddress: 'C' } + expect(rekeyTransitionFor(b, [b, c])).toEqual({ + from: AccountTypes.algo25, + to: AccountTypes.algo25, + }) + }) +}) diff --git a/packages/accounts/src/__tests__/utils.test.ts b/packages/accounts/src/__tests__/utils.test.ts index 346d62d80..e78644328 100644 --- a/packages/accounts/src/__tests__/utils.test.ts +++ b/packages/accounts/src/__tests__/utils.test.ts @@ -12,9 +12,11 @@ import { describe, test, expect } from 'vitest' import { - canSignWithAccount, + canSignWith, findAccountByKey, getAccountDisplayName, + getRekeyAccount, + getSignerFor, hasSigningKeys, isAlgo25Account, isEligibleLedgerRekeyTarget, @@ -23,10 +25,10 @@ import { isHDWalletAccount, isLedgerAccount, isMultisigAccount, - isSigningAccount, isRekeyedAccount, isWatchAccount, matchesAccountKey, + rekeyTransitionFor, resolveAuthAccount, resolveImportAccountType, } from '../utils' @@ -216,20 +218,17 @@ describe('services/accounts/utils - account type checks', () => { ).toBe(false) }) - test('canSignWithAccount returns true for account with keyPairId', () => { - expect(canSignWithAccount(baseAccount, [])).toBe(true) + test('canSignWith returns true for account with keyPairId', () => { + expect(canSignWith(baseAccount, [])).toBe(true) }) - test('canSignWithAccount returns false for account without keyPairId', () => { + test('canSignWith returns false for account without keyPairId', () => { expect( - canSignWithAccount( - { ...baseAccount, keyPairId: undefined } as any, - [], - ), + canSignWith({ ...baseAccount, keyPairId: undefined } as any, []), ).toBe(false) }) - test('canSignWithAccount returns true for rekeyed account when auth account has keys', () => { + test('canSignWith returns true for rekeyed account when auth account has keys', () => { const authAccount = { id: '2', type: 'algo25', @@ -244,10 +243,10 @@ describe('services/accounts/utils - account type checks', () => { rekeyAddress: 'AUTH_ADDR', } as any - expect(canSignWithAccount(rekeyedAccount, [authAccount])).toBe(true) + expect(canSignWith(rekeyedAccount, [authAccount])).toBe(true) }) - test('canSignWithAccount returns false for rekeyed account when auth account has no keys', () => { + test('canSignWith returns false for rekeyed account when auth account has no keys', () => { const authAccount = { id: '2', type: 'watch', @@ -261,10 +260,10 @@ describe('services/accounts/utils - account type checks', () => { rekeyAddress: 'AUTH_ADDR', } as any - expect(canSignWithAccount(rekeyedAccount, [authAccount])).toBe(false) + expect(canSignWith(rekeyedAccount, [authAccount])).toBe(false) }) - test('canSignWithAccount returns false for rekeyed account when auth account is not in list', () => { + test('canSignWith returns false for rekeyed account when auth account is not in list', () => { const rekeyedAccount = { id: '3', type: 'watch', @@ -272,10 +271,10 @@ describe('services/accounts/utils - account type checks', () => { rekeyAddress: 'AUTH_ADDR', } as any - expect(canSignWithAccount(rekeyedAccount, [])).toBe(false) + expect(canSignWith(rekeyedAccount, [])).toBe(false) }) - test('canSignWithAccount resolves a single rekey hop only, not a chain', () => { + test('canSignWith resolves a single rekey hop only, not a chain', () => { const rootAccount = { id: '1', type: 'algo25', @@ -300,12 +299,12 @@ describe('services/accounts/utils - account type checks', () => { const accounts = [rootAccount, middleAccount, leafAccount] // LEAF -> MIDDLE -> ROOT. MIDDLE holds no key, so LEAF cannot sign — // the hop from MIDDLE to ROOT is not followed. - expect(canSignWithAccount(leafAccount, accounts)).toBe(false) + expect(canSignWith(leafAccount, accounts)).toBe(false) // MIDDLE -> ROOT, and ROOT holds a key, so MIDDLE can sign (one hop). - expect(canSignWithAccount(middleAccount, accounts)).toBe(true) + expect(canSignWith(middleAccount, accounts)).toBe(true) }) - test('canSignWithAccount does not recurse on a cyclic auth chain', () => { + test('canSignWith does not recurse on a cyclic auth chain', () => { const a = { id: '1', type: 'watch', @@ -321,66 +320,209 @@ describe('services/accounts/utils - account type checks', () => { // Single-hop: A's immediate auth B holds no key — false, no infinite // recursion. - expect(canSignWithAccount(a, [a, b])).toBe(false) + expect(canSignWith(a, [a, b])).toBe(false) }) }) -describe('services/accounts/utils - isSigningAccount', () => { - test('returns false for true watch account', () => { - const account = { type: 'watch', address: 'ADDR' } as any - expect(isSigningAccount(account, [])).toBe(false) +describe('services/accounts/utils - canSignWith (hardware + multisig)', () => { + test('returns true for a non-rekeyed hardware account (no keyPairId)', () => { + const account = { + type: 'hardware', + address: 'HW', + hardwareDetails: { + manufacturer: 'ledger', + deviceId: 'test-device', + deviceName: 'Ledger Nano X', + accountIndex: 0, + transportType: 'ble', + }, + } as any + expect(canSignWith(account, [account])).toBe(true) }) - test('returns false for rekeyed account without auth in wallet (noAuth)', () => { + test('returns true for rekeyed account whose auth is a hardware account', () => { + const authAccount = { + type: 'hardware', + address: 'AUTH', + hardwareDetails: { + manufacturer: 'ledger', + deviceId: 'test-device', + deviceName: 'Ledger Nano X', + accountIndex: 0, + transportType: 'ble', + }, + } as any const account = { type: 'watch', address: 'ADDR', - rekeyAddress: 'MISSING_AUTH', + rekeyAddress: 'AUTH', } as any - expect(isSigningAccount(account, [])).toBe(false) + expect(canSignWith(account, [account, authAccount])).toBe(true) }) - test('returns true for rekeyed account with auth present (rekeyedStandard)', () => { - const authAccount = { + test('returns true for a multisig with a local signable participant', () => { + const participant = { + type: 'algo25', + address: 'P1', + keyPairId: 'pk1', + } as any + const multisig = { + type: 'multisig', + address: 'MS', + multisigDetails: { threshold: 2, addresses: ['P1', 'P2'] }, + } as any + expect(canSignWith(multisig, [multisig, participant])).toBe(true) + }) + + test('returns false for a multisig with no local signable participants', () => { + const multisig = { + type: 'multisig', + address: 'MS', + multisigDetails: { threshold: 2, addresses: ['P1', 'P2'] }, + } as any + expect(canSignWith(multisig, [multisig])).toBe(false) + }) +}) + +describe('services/accounts/utils - getRekeyAccount', () => { + test('returns the auth account when rekeyed and target is in the wallet', () => { + const auth = { type: 'algo25', address: 'AUTH', keyPairId: 'pk1', } as any + const rekeyed = { + type: 'algo25', + address: 'A', + keyPairId: 'pk2', + rekeyAddress: 'AUTH', + } as any + expect(getRekeyAccount('A', [rekeyed, auth])).toBe(auth) + }) + + test('returns null when the address is not rekeyed', () => { const account = { + type: 'algo25', + address: 'A', + keyPairId: 'pk1', + } as any + expect(getRekeyAccount('A', [account])).toBeNull() + }) + + test('returns null when the rekey target is not in the wallet', () => { + const rekeyed = { type: 'watch', - address: 'ADDR', + address: 'A', + rekeyAddress: 'MISSING', + } as any + expect(getRekeyAccount('A', [rekeyed])).toBeNull() + }) + + test('returns null when the address is unknown', () => { + expect(getRekeyAccount('UNKNOWN', [])).toBeNull() + }) +}) + +describe('services/accounts/utils - getSignerFor', () => { + test('returns the account itself when it holds its own key', () => { + const account = { + type: 'algo25', + address: 'A', + keyPairId: 'pk1', + } as any + expect(getSignerFor('A', [account])).toBe(account) + }) + + test('returns the immediate auth account when rekeyed and we can sign', () => { + const auth = { + type: 'algo25', + address: 'AUTH', + keyPairId: 'pk1', + } as any + const rekeyed = { + type: 'algo25', + address: 'A', + keyPairId: 'pk2', rekeyAddress: 'AUTH', } as any - expect(isSigningAccount(account, [authAccount])).toBe(true) + expect(getSignerFor('A', [rekeyed, auth])).toBe(auth) }) - test('returns true for rekeyed account with ledger auth present (rekeyedLedger)', () => { - const authAccount = { + test('returns null for an unsignable rekeyed account', () => { + const rekeyed = { + type: 'watch', + address: 'A', + rekeyAddress: 'MISSING', + } as any + expect(getSignerFor('A', [rekeyed])).toBeNull() + }) + + test('returns null for a non-rekeyed watch account', () => { + const account = { type: 'watch', address: 'A' } as any + expect(getSignerFor('A', [account])).toBeNull() + }) + + test('returns the multisig itself when at least one participant is local and signable', () => { + const participant = { + type: 'algo25', + address: 'P1', + keyPairId: 'pk1', + } as any + const multisig = { + type: 'multisig', + address: 'MS', + multisigDetails: { threshold: 2, addresses: ['P1', 'P2'] }, + } as any + expect(getSignerFor('MS', [multisig, participant])).toBe(multisig) + }) + + test('returns null when address is not in the wallet', () => { + expect(getSignerFor('UNKNOWN', [])).toBeNull() + }) +}) + +describe('services/accounts/utils - rekeyTransitionFor', () => { + test('returns null for a non-rekeyed account', () => { + const account = { + type: 'algo25', + address: 'A', + keyPairId: 'pk1', + } as any + expect(rekeyTransitionFor(account, [account])).toBeNull() + }) + + test('returns null for a rekeyed account whose auth is not in the wallet', () => { + const rekeyed = { + type: 'algo25', + address: 'A', + keyPairId: 'pk1', + rekeyAddress: 'MISSING', + } as any + expect(rekeyTransitionFor(rekeyed, [rekeyed])).toBeNull() + }) + + test('returns from/to raw types for a signable rekey', () => { + const auth = { type: 'hardware', address: 'AUTH', hardwareDetails: { manufacturer: 'ledger', - deviceId: 'test-device', - deviceName: 'Ledger Nano X', + deviceId: 'd', + deviceName: 'Ledger', accountIndex: 0, transportType: 'ble', }, } as any - const account = { - type: 'watch', - address: 'ADDR', - rekeyAddress: 'AUTH', - } as any - expect(isSigningAccount(account, [authAccount])).toBe(true) - }) - - test('returns true for standard account', () => { - const account = { + const rekeyed = { type: 'algo25', - address: 'ADDR', + address: 'A', keyPairId: 'pk1', + rekeyAddress: 'AUTH', } as any - expect(isSigningAccount(account, [])).toBe(true) + expect(rekeyTransitionFor(rekeyed, [rekeyed, auth])).toEqual({ + from: 'algo25', + to: 'hardware', + }) }) }) diff --git a/packages/accounts/src/hooks/__tests__/useAccountLogicalType.test.ts b/packages/accounts/src/hooks/__tests__/useAccountLogicalType.test.ts deleted file mode 100644 index ad838e25b..000000000 --- a/packages/accounts/src/hooks/__tests__/useAccountLogicalType.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* - Copyright 2022-2025 Pera Wallet, LDA - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License - */ - -import { renderHook } from '@testing-library/react' -import { describe, expect, it, beforeEach } from 'vitest' -import { useAccountLogicalType } from '../useAccountLogicalType' -import { useAllAccountLogicalTypes } from '../useAllAccountLogicalTypes' -import { AccountLogicalTypes } from '../../logical-type' -import { useAccountsStore } from '../../store' -import type { WalletAccount } from '../../models' - -describe('useAccountLogicalType', () => { - beforeEach(() => { - useAccountsStore.getState().resetState() - }) - - it('returns RekeyedAuth when the stored rekeyAddress points to a signer in the store', () => { - useAccountsStore.getState().setAccounts([ - { - type: 'watch', - address: 'A', - rekeyAddress: 'S', - } as unknown as WalletAccount, - { - type: 'algo25', - address: 'S', - keyPairId: 'k', - } as unknown as WalletAccount, - ]) - - const { result } = renderHook(() => useAccountLogicalType('A')) - expect(result.current).toBe(AccountLogicalTypes.RekeyedAuth) - }) - - it('returns Algo25 for a plain signer with no rekey', () => { - useAccountsStore.getState().setAccounts([ - { - type: 'algo25', - address: 'S', - keyPairId: 'k', - } as unknown as WalletAccount, - ]) - const { result } = renderHook(() => useAccountLogicalType('S')) - expect(result.current).toBe(AccountLogicalTypes.Algo25) - }) - - it('returns null for an unknown address', () => { - useAccountsStore.getState().setAccounts([]) - const { result } = renderHook(() => useAccountLogicalType('Z')) - expect(result.current).toBeNull() - }) -}) - -describe('useAllAccountLogicalTypes', () => { - beforeEach(() => { - useAccountsStore.getState().resetState() - }) - - it('returns a Map keyed by address', () => { - useAccountsStore.getState().setAccounts([ - { - type: 'watch', - address: 'A', - rekeyAddress: 'S', - } as unknown as WalletAccount, - { - type: 'algo25', - address: 'S', - keyPairId: 'k', - } as unknown as WalletAccount, - ]) - const { result } = renderHook(() => useAllAccountLogicalTypes()) - expect(result.current.get('A')).toBe(AccountLogicalTypes.RekeyedAuth) - expect(result.current.get('S')).toBe(AccountLogicalTypes.Algo25) - }) -}) diff --git a/packages/accounts/src/hooks/__tests__/useSignerFor.test.ts b/packages/accounts/src/hooks/__tests__/useSignerFor.test.ts new file mode 100644 index 000000000..6b7181442 --- /dev/null +++ b/packages/accounts/src/hooks/__tests__/useSignerFor.test.ts @@ -0,0 +1,110 @@ +/* + 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 { renderHook } from '@testing-library/react' +import { describe, expect, it, beforeEach } from 'vitest' +import { useSignerFor } from '../useSignerFor' +import { useCanSignWith } from '../useCanSignWith' +import { useRekeyAccount } from '../useRekeyAccount' +import { useAccountsStore } from '../../store' +import type { WalletAccount } from '../../models' + +const setAccounts = (accounts: WalletAccount[]) => + useAccountsStore.getState().setAccounts(accounts) + +describe('useSignerFor', () => { + beforeEach(() => { + useAccountsStore.getState().resetState() + }) + + it('returns the auth account when rekeyed to a local signer', () => { + setAccounts([ + { type: 'watch', address: 'A', rekeyAddress: 'S' } as WalletAccount, + { type: 'algo25', address: 'S', keyPairId: 'k' } as WalletAccount, + ]) + const { result } = renderHook(() => useSignerFor('A')) + expect(result.current?.address).toBe('S') + }) + + it('returns the account itself when it holds its own key', () => { + setAccounts([ + { type: 'algo25', address: 'A', keyPairId: 'k' } as WalletAccount, + ]) + const { result } = renderHook(() => useSignerFor('A')) + expect(result.current?.address).toBe('A') + }) + + it('returns null for an unknown address', () => { + setAccounts([]) + const { result } = renderHook(() => useSignerFor('Z')) + expect(result.current).toBeNull() + }) +}) + +describe('useCanSignWith', () => { + beforeEach(() => { + useAccountsStore.getState().resetState() + }) + + it('returns true for a signable account', () => { + const account = { + type: 'algo25', + address: 'A', + keyPairId: 'k', + } as WalletAccount + setAccounts([account]) + const { result } = renderHook(() => useCanSignWith(account)) + expect(result.current).toBe(true) + }) + + it('returns false for a watch account', () => { + const account = { type: 'watch', address: 'A' } as WalletAccount + setAccounts([account]) + const { result } = renderHook(() => useCanSignWith(account)) + expect(result.current).toBe(false) + }) +}) + +describe('useRekeyAccount', () => { + beforeEach(() => { + useAccountsStore.getState().resetState() + }) + + it('returns the rekey target when present in the wallet', () => { + setAccounts([ + { type: 'watch', address: 'A', rekeyAddress: 'S' } as WalletAccount, + { type: 'algo25', address: 'S', keyPairId: 'k' } as WalletAccount, + ]) + const { result } = renderHook(() => useRekeyAccount('A')) + expect(result.current?.address).toBe('S') + }) + + it('returns null when the account is not rekeyed', () => { + setAccounts([ + { type: 'algo25', address: 'A', keyPairId: 'k' } as WalletAccount, + ]) + const { result } = renderHook(() => useRekeyAccount('A')) + expect(result.current).toBeNull() + }) + + it('returns null when the rekey target is unknown locally', () => { + setAccounts([ + { + type: 'watch', + address: 'A', + rekeyAddress: 'MISSING', + } as WalletAccount, + ]) + const { result } = renderHook(() => useRekeyAccount('A')) + expect(result.current).toBeNull() + }) +}) diff --git a/packages/accounts/src/hooks/index.ts b/packages/accounts/src/hooks/index.ts index 8ba1404ff..f1c35ee32 100644 --- a/packages/accounts/src/hooks/index.ts +++ b/packages/accounts/src/hooks/index.ts @@ -37,8 +37,9 @@ export * from './useHDWalletGroups' export * from './useLedgerDeviceGroups' export * from './useSortedAccounts' export * from './useSortedAssetBalances' -export * from './useAllAccountLogicalTypes' -export * from './useAccountLogicalType' +export * from './useRekeyAccount' +export * from './useSignerFor' +export * from './useCanSignWith' export * from './useRekeyTransition' export * from './useOwnedAssets' export * from './useHDImportSession' diff --git a/packages/accounts/src/hooks/useAccountLogicalType.ts b/packages/accounts/src/hooks/useCanSignWith.ts similarity index 62% rename from packages/accounts/src/hooks/useAccountLogicalType.ts rename to packages/accounts/src/hooks/useCanSignWith.ts index 9f97adba4..f5a900c57 100644 --- a/packages/accounts/src/hooks/useAccountLogicalType.ts +++ b/packages/accounts/src/hooks/useCanSignWith.ts @@ -10,12 +10,9 @@ limitations under the License */ -import { useAllAccountLogicalTypes } from './useAllAccountLogicalTypes' -import type { AccountLogicalType } from '../logical-type' +import { useSignerFor } from './useSignerFor' +import type { WalletAccount } from '../models' -export const useAccountLogicalType = ( - address: string | undefined | null, -): AccountLogicalType | null => { - const map = useAllAccountLogicalTypes() - return address ? (map.get(address) ?? null) : null -} +export const useCanSignWith = ( + account: WalletAccount | null | undefined, +): boolean => useSignerFor(account?.address) !== null diff --git a/packages/accounts/src/hooks/useAllAccountLogicalTypes.ts b/packages/accounts/src/hooks/useRekeyAccount.ts similarity index 59% rename from packages/accounts/src/hooks/useAllAccountLogicalTypes.ts rename to packages/accounts/src/hooks/useRekeyAccount.ts index b3b4281ef..a8601d8c6 100644 --- a/packages/accounts/src/hooks/useAllAccountLogicalTypes.ts +++ b/packages/accounts/src/hooks/useRekeyAccount.ts @@ -11,26 +11,16 @@ */ import { useMemo } from 'react' -import { - deriveAccountLogicalType, - type AccountLogicalType, -} from '../logical-type' +import { getRekeyAccount } from '../utils' import { useAccountsStore } from '../store' +import type { WalletAccount } from '../models' -export const useAllAccountLogicalTypes = (): Map< - string, - AccountLogicalType -> => { +export const useRekeyAccount = ( + address: string | undefined | null, +): WalletAccount | null => { const accounts = useAccountsStore(state => state.accounts) - - return useMemo(() => { - const map = new Map() - for (const account of accounts) { - map.set( - account.address, - deriveAccountLogicalType(account, accounts), - ) - } - return map - }, [accounts]) + return useMemo( + () => (address ? getRekeyAccount(address, accounts) : null), + [address, accounts], + ) } diff --git a/packages/accounts/src/hooks/useRekeyTransition.ts b/packages/accounts/src/hooks/useRekeyTransition.ts index ae1e51941..4e2ddbbde 100644 --- a/packages/accounts/src/hooks/useRekeyTransition.ts +++ b/packages/accounts/src/hooks/useRekeyTransition.ts @@ -11,7 +11,7 @@ */ import { useMemo } from 'react' -import { rekeyTransitionFor, type RekeyTransition } from '../logical-type' +import { rekeyTransitionFor, type RekeyTransition } from '../utils' import { useAccountsStore } from '../store' export const useRekeyTransition = ( diff --git a/packages/accounts/src/hooks/useSignerFor.ts b/packages/accounts/src/hooks/useSignerFor.ts new file mode 100644 index 000000000..606c351a0 --- /dev/null +++ b/packages/accounts/src/hooks/useSignerFor.ts @@ -0,0 +1,26 @@ +/* + 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 { getSignerFor } from '../utils' +import { useAccountsStore } from '../store' +import type { WalletAccount } from '../models' + +export const useSignerFor = ( + address: string | undefined | null, +): WalletAccount | null => { + const accounts = useAccountsStore(state => state.accounts) + return useMemo( + () => (address ? getSignerFor(address, accounts) : null), + [address, accounts], + ) +} diff --git a/packages/accounts/src/hooks/useSigningAccounts.ts b/packages/accounts/src/hooks/useSigningAccounts.ts index 1c140a992..10a477eed 100644 --- a/packages/accounts/src/hooks/useSigningAccounts.ts +++ b/packages/accounts/src/hooks/useSigningAccounts.ts @@ -12,12 +12,12 @@ import { useMemo } from 'react' import { useAccountsStore } from '../store' -import { isSigningAccount } from '../utils' +import { canSignWith } from '../utils' export const useSigningAccounts = () => { const accounts = useAccountsStore(state => state.accounts) return useMemo( - () => accounts.filter(account => isSigningAccount(account, accounts)), + () => accounts.filter(account => canSignWith(account, accounts)), [accounts], ) } diff --git a/packages/accounts/src/index.ts b/packages/accounts/src/index.ts index 0d2f07a22..563758461 100644 --- a/packages/accounts/src/index.ts +++ b/packages/accounts/src/index.ts @@ -17,7 +17,6 @@ export * from './models' export * from './hooks' export * from './errors' export * from './utils' -export * from './logical-type' export * from './bip44' export * from './account-discovery' export * from './db' diff --git a/packages/accounts/src/logical-type.ts b/packages/accounts/src/logical-type.ts deleted file mode 100644 index 0189b35c9..000000000 --- a/packages/accounts/src/logical-type.ts +++ /dev/null @@ -1,144 +0,0 @@ -/* - Copyright 2022-2025 Pera Wallet, LDA - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License - */ - -import { AccountTypes, type WalletAccount } from './models' - -export const AccountLogicalTypes = { - Algo25: 'Algo25', - HdKey: 'HdKey', - LedgerBle: 'LedgerBle', - Multisig: 'Multisig', - Rekeyed: 'Rekeyed', - RekeyedAuth: 'RekeyedAuth', - NoAuth: 'NoAuth', -} as const - -export type AccountLogicalType = - (typeof AccountLogicalTypes)[keyof typeof AccountLogicalTypes] - -const canSignDirectly = (account: WalletAccount): boolean => - !!account.keyPairId || account.type === AccountTypes.hardware - -/** - * True when `account` is a multisig the wallet can sign for: it holds at - * least one participant that can sign with its own key. Multisig signing is - * propose-based, so a single local participant is enough — mirrors the - * `getLocalParticipants` rule in the signing pipeline. - */ -const canSignViaMultisig = ( - account: WalletAccount, - accounts: WalletAccount[], -): boolean => { - if (account.type !== AccountTypes.multisig) return false - return account.multisigDetails.addresses.some(participantAddress => { - const participant = accounts.find(a => a.address === participantAddress) - return !!participant && canSignDirectly(participant) - }) -} - -export const baseTypeFor = (account: WalletAccount): AccountLogicalType => { - switch (account.type) { - case AccountTypes.hdWallet: - return AccountLogicalTypes.HdKey - case AccountTypes.hardware: - return AccountLogicalTypes.LedgerBle - case AccountTypes.multisig: - return AccountLogicalTypes.Multisig - case AccountTypes.algo25: - return AccountLogicalTypes.Algo25 - case AccountTypes.watch: - return AccountLogicalTypes.NoAuth - } -} - -/** - * Derives the logical type of `account` given the full wallet list. The - * account's `rekeyAddress` is kept in sync by `fetchAndPersistAccount`, so - * it reflects the current on-chain auth address. - * - * Classification: - * 1. If rekeyed and the immediate auth account is one we can sign with — - * a key we hold, or a multisig we hold a signable participant of → - * RekeyedAuth. - * 2. If rekeyed but we can't sign with the immediate auth account → - * Rekeyed. (Applies regardless of whether the original account was a - * watch — what matters visually is that the account IS rekeyed; the - * "can we sign?" answer surfaces at submission time via algod.) - * 3. Otherwise → map from stored account type. Watch accounts that are - * not rekeyed still classify as NoAuth. - * - * The signing-capability check is a single rekey hop: rekey indirection is - * not transitive, so we only consult the immediate auth account. - */ -export const deriveAccountLogicalType = ( - account: WalletAccount, - accounts: WalletAccount[], -): AccountLogicalType => { - const authAddress = account.rekeyAddress ?? null - - if (!authAddress) { - return baseTypeFor(account) - } - - const authAccount = accounts.find(a => a.address === authAddress) - const authCanSign = authAccount - ? canSignDirectly(authAccount) || - canSignViaMultisig(authAccount, accounts) - : false - - return authCanSign - ? AccountLogicalTypes.RekeyedAuth - : AccountLogicalTypes.Rekeyed -} - -/** - * Convenience: true when the account can sign transactions in this wallet. - * Matches `isSigningAccount` semantics — returns false for NoAuth and - * Rekeyed (rekeyed but we don't hold the auth keys). - */ -export const isSigningLogicalType = (type: AccountLogicalType): boolean => - type !== AccountLogicalTypes.NoAuth && type !== AccountLogicalTypes.Rekeyed - -export type RekeyTransition = { - /** Base type of the rekeyed account itself. */ - from: AccountLogicalType - /** Base type of the account it is now rekeyed to. */ - to: AccountLogicalType -} - -/** - * For a signable rekeyed account (`RekeyedAuth`), returns the base type of the - * account and of its immediate auth account, so the UI can render a - * "Rekeyed ( to )" label. Returns null when the account is not a - * signable rekey, or when the auth account is not held in this wallet (e.g. a - * multi-hop chain where the immediate auth address is unknown locally). - */ -export const rekeyTransitionFor = ( - account: WalletAccount, - accounts: WalletAccount[], -): RekeyTransition | null => { - if ( - deriveAccountLogicalType(account, accounts) !== - AccountLogicalTypes.RekeyedAuth - ) { - return null - } - if (!account.rekeyAddress) return null - - const authAccount = accounts.find(a => a.address === account.rekeyAddress) - if (!authAccount) return null - - return { - from: baseTypeFor(account), - to: baseTypeFor(authAccount), - } -} diff --git a/packages/accounts/src/utils.ts b/packages/accounts/src/utils.ts index b9a02bc73..9ab73803a 100644 --- a/packages/accounts/src/utils.ts +++ b/packages/accounts/src/utils.ts @@ -26,7 +26,6 @@ import { } from './models' import { MNEMONIC_WORD_COUNT } from './constants' import { RekeyTargetNotFoundError } from './errors' -import { deriveAccountLogicalType, isSigningLogicalType } from './logical-type' export const getAccountDisplayName = (account: Nullable) => { if (!account) return 'No Account' @@ -83,26 +82,124 @@ export const hasSigningKeys = (account: WalletAccount): boolean => { } /** - * True when the wallet can sign for `account`: it holds the account's own - * key, or the account is rekeyed and the wallet holds the immediate auth - * account's key. - * - * Resolves a single rekey hop only — rekey indirection is not transitive - * (consistent with `resolveAuthAccount`). A non-recursive check also cannot - * infinite-loop on a cyclic auth chain (A → B → A). + * True iff the wallet holds key material that produces a signature for + * `account` directly: either its own key, or a Ledger device for hardware + * accounts. Used by `getSignerFor` to test signing capability on a single + * account without following rekeys. + */ +const canSignDirectly = (account: WalletAccount): boolean => + hasSigningKeys(account) || isHardwareWalletAccount(account) + +/** + * True iff `multisig` has at least one participant in `accounts` that can + * sign with its own key. Multisig signing is propose-based, so a single + * local signable participant is enough. + */ +const canSignViaMultisig = ( + multisig: MultiSigAccount, + accounts: WalletAccount[], +): boolean => + multisig.multisigDetails.addresses.some(addr => { + const participant = accounts.find(a => a.address === addr) + return !!participant && canSignDirectly(participant) + }) + +/** + * Internal: resolve the account that signs for `account`, or null when nothing + * in `accounts` can. Shared by `getSignerFor` (address-keyed) and + * `canSignWith` (account-in-hand) so the single set of rules — rekey hop, + * multisig participant rule, direct signability — lives in one place. */ -export const canSignWithAccount = ( +const signerForAccount = ( account: WalletAccount, accounts: WalletAccount[], -): boolean => { - if (hasSigningKeys(account)) return true +): WalletAccount | null => { if (account.rekeyAddress) { - const authAccount = accounts.find( - a => a.address === account.rekeyAddress, - ) - return !!authAccount && hasSigningKeys(authAccount) + const auth = accounts.find(a => a.address === account.rekeyAddress) + if (!auth) return null + if (canSignDirectly(auth)) return auth + if (isMultisigAccount(auth) && canSignViaMultisig(auth, accounts)) { + return auth + } + return null + } + if (isMultisigAccount(account)) { + return canSignViaMultisig(account, accounts) ? account : null } - return false + return canSignDirectly(account) ? account : null +} + +/** + * Returns the auth account `address` is rekeyed to. Null when `address` is + * not in the wallet, is not rekeyed, or its rekey target is not held locally. + * + * Use this for the immediate auth-addr relationship; for "who actually signs", + * use `getSignerFor` which also accounts for multisig participant resolution + * and signability. + */ +export const getRekeyAccount = ( + address: string, + accounts: WalletAccount[], +): WalletAccount | null => { + const account = accounts.find(a => a.address === address) + if (!account?.rekeyAddress) return null + return accounts.find(a => a.address === account.rekeyAddress) ?? null +} + +/** + * Returns the account that will produce signatures for `address` in this + * wallet. Resolves a single rekey hop and multisig participation. + * + * - Address not in wallet → null + * - Rekeyed, auth account locally signable → the auth account + * - Rekeyed, auth account missing or not signable → null + * - Multisig with at least one local signable participant → the multisig + * - Standard/HD with its own key, or Hardware → the account itself + * - Watch (not rekeyed) → null + * + * Single-hop only: cyclic and multi-hop auth chains are not followed. + */ +export const getSignerFor = ( + address: string, + accounts: WalletAccount[], +): WalletAccount | null => { + const account = accounts.find(a => a.address === address) + if (!account) return null + return signerForAccount(account, accounts) +} + +/** + * True when the wallet can produce signatures for `account`. Unlike + * `getSignerFor`, this does not require `account` to be present in + * `accounts` — pass the account in hand. + */ +export const canSignWith = ( + account: WalletAccount, + accounts: WalletAccount[], +): boolean => signerForAccount(account, accounts) !== null + +export type RekeyTransition = { + /** Raw type of the rekeyed account itself. */ + from: WalletAccount['type'] + /** Raw type of the account it is now rekeyed to. */ + to: WalletAccount['type'] +} + +/** + * For a rekeyed account whose auth we can sign with, returns the types of the + * rekeyed account and its immediate auth account, so the UI can render a + * "Rekeyed ( to )" label. Returns null when the account is not a + * signable rekey, or when the auth account is not held locally. + */ +export const rekeyTransitionFor = ( + account: WalletAccount, + accounts: WalletAccount[], +): RekeyTransition | null => { + if (!account.rekeyAddress) return null + const auth = accounts.find(a => a.address === account.rekeyAddress) + if (!auth) return null + if (!canSignWith(account, accounts)) return null + return { from: account.type, to: auth.type } } /** @@ -168,35 +265,11 @@ export const isEligibleSharedRekeyTarget = ( allAccounts: WalletAccount[], ): boolean => { if (target.address === sourceAddress) return false - if (target.type !== AccountTypes.multisig) return false + if (!isMultisigAccount(target)) return false if (target.rekeyAddress) return false - - const heldByAddress = new Map(allAccounts.map(a => [a.address, a])) - const signableParticipants = target.multisigDetails.addresses.filter( - addr => { - const participant = heldByAddress.get(addr) - return ( - !!participant && - (hasSigningKeys(participant) || - isHardwareWalletAccount(participant)) - ) - }, - ).length - if (signableParticipants < 1) return false - - return true + return canSignViaMultisig(target, allAccounts) } -/** - * Returns true if the account can sign transactions in this wallet. Delegates - * to `deriveAccountLogicalType` — the single source of truth — so the result - * is consistent with UI classification and the webview bridge payload. - */ -export const isSigningAccount = ( - account: WalletAccount, - accounts: WalletAccount[], -): boolean => isSigningLogicalType(deriveAccountLogicalType(account, accounts)) - /** * Resolves the auth account that signs for `account` — a single rekey hop. * diff --git a/packages/kms/src/crypto/__tests__/random.test.ts b/packages/kms/src/crypto/__tests__/random.test.ts new file mode 100644 index 000000000..696970559 --- /dev/null +++ b/packages/kms/src/crypto/__tests__/random.test.ts @@ -0,0 +1,117 @@ +/* + 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, afterEach } from 'vitest' +import { uniformIntBelow, pickDistinctIndexes } from '../random' + +afterEach(() => { + vi.restoreAllMocks() +}) + +/** Helper: stub `crypto.getRandomValues` to yield a fixed sequence of u32s. */ +const stubRandomValues = (values: number[]) => { + let i = 0 + return vi + .spyOn(crypto, 'getRandomValues') + .mockImplementation((buf: T): T => { + if (!buf) return buf + const arr = buf as unknown as Uint32Array + arr[0] = values[i++ % values.length] + return buf + }) +} + +describe('uniformIntBelow', () => { + it('returns 0 when max <= 0', () => { + expect(uniformIntBelow(0)).toBe(0) + expect(uniformIntBelow(-1)).toBe(0) + }) + + it('returns 0 when max is 1', () => { + // Every drawn value is < limit (0x100000000) and `value % 1 === 0`. + stubRandomValues([42]) + expect(uniformIntBelow(1)).toBe(0) + }) + + it('returns value % max for a value below the rejection limit', () => { + // max = 10 → limit = floor(2^32 / 10) * 10 = 4_294_967_290. + // value = 7 is below limit → returned as 7 % 10 = 7. + stubRandomValues([7]) + expect(uniformIntBelow(10)).toBe(7) + }) + + it('rejects and resamples when value >= limit (debiased rejection sampling)', () => { + // max = 10 → limit = 4_294_967_290. + // First sample 4_294_967_295 is above the limit and must be rejected. + // Second sample 3 is accepted → returned as 3. + const spy = stubRandomValues([0xffffffff, 3]) + expect(uniformIntBelow(10)).toBe(3) + expect(spy).toHaveBeenCalledTimes(2) + }) + + it('produces values uniformly distributed across [0, max)', () => { + // Smoke-check distribution: with the real CSRNG, draws across a small + // range should hit every bucket over a modest sample size. + const buckets = new Array(5).fill(0) + for (let i = 0; i < 500; i++) { + buckets[uniformIntBelow(5)]++ + } + for (const count of buckets) { + expect(count).toBeGreaterThan(0) + } + }) +}) + +describe('pickDistinctIndexes', () => { + it('returns the full pool when count >= poolSize', () => { + const result = pickDistinctIndexes(10, 5) + expect(result).toHaveLength(5) + expect([...result].sort((a, b) => a - b)).toEqual([0, 1, 2, 3, 4]) + }) + + it('returns count distinct indexes from [0, poolSize)', () => { + const result = pickDistinctIndexes(3, 10) + expect(result).toHaveLength(3) + expect(new Set(result).size).toBe(3) + for (const idx of result) { + expect(idx).toBeGreaterThanOrEqual(0) + expect(idx).toBeLessThan(10) + } + }) + + it('returns an empty array when count is 0', () => { + expect(pickDistinctIndexes(0, 10)).toEqual([]) + }) + + it('returns an empty array when count is negative', () => { + expect(pickDistinctIndexes(-3, 10)).toEqual([]) + }) + + it('returns an empty array when the pool is empty', () => { + expect(pickDistinctIndexes(5, 0)).toEqual([]) + }) + + it('shuffles using uniformIntBelow — sample is not necessarily sorted', () => { + // Across many runs, at least one should come back out of natural order; + // otherwise the shuffle has degenerated. + let sawUnsorted = false + for (let i = 0; i < 50; i++) { + const result = pickDistinctIndexes(10, 10) + const sorted = [...result].sort((a, b) => a - b) + if (result.some((v, idx) => v !== sorted[idx])) { + sawUnsorted = true + break + } + } + expect(sawUnsorted).toBe(true) + }) +}) diff --git a/packages/signing/src/hooks/__tests__/useSigningPipeline.spec.ts b/packages/signing/src/hooks/__tests__/useSigningPipeline.spec.ts index 476b2724f..18abbfc01 100644 --- a/packages/signing/src/hooks/__tests__/useSigningPipeline.spec.ts +++ b/packages/signing/src/hooks/__tests__/useSigningPipeline.spec.ts @@ -26,17 +26,24 @@ vi.mock('../useSigningRequest', () => ({ useSigningRequest: () => mockSigningRequest, })) +const mockAllAccounts = vi.fn<() => Array<{ address: string; type: string }>>( + () => [ + { address: 'ADDR_A', type: 'algo25' }, + { address: 'ADDR_B', type: 'algo25' }, + ], +) +const mockCanSignWith = vi.fn<(account: { address: string }) => boolean>( + () => true, +) + vi.mock('@perawallet/wallet-core-accounts', async () => { const actual = await vi.importActual( '@perawallet/wallet-core-accounts', ) return { ...actual, - useAllAccounts: () => [ - { address: 'ADDR_A', type: 'algo25' }, - { address: 'ADDR_B', type: 'algo25' }, - ], - canSignWithAccount: () => true, + useAllAccounts: () => mockAllAccounts(), + canSignWith: (account: { address: string }) => mockCanSignWith(account), } }) @@ -58,6 +65,11 @@ beforeEach(() => { mockSigningRequest.signAndSendRequest.mockReset() mockSigningRequest.rejectRequest.mockReset() mockSigningRequest.retryRequest.mockReset() + mockAllAccounts.mockReturnValue([ + { address: 'ADDR_A', type: 'algo25' }, + { address: 'ADDR_B', type: 'algo25' }, + ]) + mockCanSignWith.mockReturnValue(true) }) describe('useSigningPipeline', () => { @@ -145,6 +157,64 @@ describe('useSigningPipeline', () => { expect(onEvent).toHaveBeenCalledWith({ type: 'signing_rejected' }) }) + test('signableAddresses contains only accounts where canSignWith returns true', () => { + mockAllAccounts.mockReturnValue([ + { address: 'SIGNER', type: 'algo25' }, + { address: 'WATCH', type: 'watch' }, + { address: 'REKEYED_UNSIGNABLE', type: 'watch' }, + ]) + mockCanSignWith.mockImplementation(a => a.address === 'SIGNER') + + const request: TransactionSignRequest = { + id: 'req-1', + type: 'transactions', + transport: 'algod', + txs: [], + } + mockSigningRequest.currentRequest = request + + const { result } = renderHook(() => useSigningPipeline()) + + expect(result.current.signableAddresses.has('SIGNER')).toBe(true) + expect(result.current.signableAddresses.has('WATCH')).toBe(false) + expect(result.current.signableAddresses.has('REKEYED_UNSIGNABLE')).toBe( + false, + ) + expect(result.current.signableAddresses.size).toBe(1) + }) + + test('signableAddresses is empty when no accounts pass canSignWith', () => { + mockAllAccounts.mockReturnValue([ + { address: 'A', type: 'watch' }, + { address: 'B', type: 'watch' }, + ]) + mockCanSignWith.mockReturnValue(false) + + mockSigningRequest.currentRequest = { + id: 'req-1', + type: 'transactions', + transport: 'algod', + txs: [], + } as TransactionSignRequest + + const { result } = renderHook(() => useSigningPipeline()) + expect(result.current.signableAddresses.size).toBe(0) + }) + + test('signableAddresses is empty when there are no accounts at all', () => { + mockAllAccounts.mockReturnValue([]) + + mockSigningRequest.currentRequest = { + id: 'req-1', + type: 'transactions', + transport: 'algod', + txs: [], + } as TransactionSignRequest + + const { result } = renderHook(() => useSigningPipeline()) + expect(result.current.signableAddresses.size).toBe(0) + }) + test('isLoading is true during signing stage', () => { const subscriberCalls: Array<(s: unknown) => void> = [] mockSigningRequest.currentActorRef = { diff --git a/packages/signing/src/hooks/useSigningPipeline.ts b/packages/signing/src/hooks/useSigningPipeline.ts index c1221f436..c0ddd2495 100644 --- a/packages/signing/src/hooks/useSigningPipeline.ts +++ b/packages/signing/src/hooks/useSigningPipeline.ts @@ -14,10 +14,7 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react' import type { AnyActorRef } from 'xstate' import type { PeraDisplayableTransaction } from '@perawallet/wallet-core-blockchain' import { mapToDisplayableTransaction } from '@perawallet/wallet-core-blockchain' -import { - canSignWithAccount, - useAllAccounts, -} from '@perawallet/wallet-core-accounts' +import { canSignWith, useAllAccounts } from '@perawallet/wallet-core-accounts' import type { PipelineStage, TransactionSignRequest } from '../models' import { createTransactionListItems, @@ -82,9 +79,7 @@ export const useSigningPipeline = ( const listItems = createTransactionListItems(allTransactions) const signableAddresses = new Set( - accounts - .filter(a => canSignWithAccount(a, accounts)) - .map(a => a.address), + accounts.filter(a => canSignWith(a, accounts)).map(a => a.address), ) const userAccountAddresses = new Set(accounts.map(a => a.address)) diff --git a/packages/transactions/src/hooks/__tests__/useRekeyTransactionFeeQuery.spec.ts b/packages/transactions/src/hooks/__tests__/useRekeyTransactionFeeQuery.spec.ts new file mode 100644 index 000000000..4ed2817f9 --- /dev/null +++ b/packages/transactions/src/hooks/__tests__/useRekeyTransactionFeeQuery.spec.ts @@ -0,0 +1,147 @@ +/* + 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 React from 'react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import { Decimal } from 'decimal.js' + +// algokit-utils adds BigInt.prototype.microAlgo() at runtime; patch for tests. +;(BigInt.prototype as unknown as { microAlgo: () => bigint }).microAlgo = + function () { + return this as unknown as bigint + } + +const mockPayment = vi.fn() +const mockAlgokit = { createTransaction: { payment: mockPayment } } +const mockUseNetwork = vi.fn(() => ({ network: 'mainnet' })) + +vi.mock('@perawallet/wallet-core-blockchain', () => ({ + useAlgorandClient: () => mockAlgokit, + useNetwork: () => mockUseNetwork(), + MIN_TXN_FEE: 1000n, + microAlgosToAlgos: (microAlgos: bigint) => + new Decimal(microAlgos.toString()).dividedBy(1_000_000), +})) + +import { useRekeyTransactionFeeQuery } from '../useRekeyTransactionFeeQuery' + +const buildWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + return { + queryClient, + wrapper: ({ children }: { children: React.ReactNode }) => + React.createElement( + QueryClientProvider, + { client: queryClient }, + children, + ), + } +} + +beforeEach(() => { + vi.clearAllMocks() + mockUseNetwork.mockReturnValue({ network: 'mainnet' }) +}) + +describe('useRekeyTransactionFeeQuery', () => { + it('resolves to feeAlgos derived from the built transaction fee', async () => { + // AlgoKit returned a 2000 microAlgo fee → 0.002 ALGO. + mockPayment.mockResolvedValueOnce({ fee: 2000n }) + const { wrapper } = buildWrapper() + + const { result } = renderHook( + () => useRekeyTransactionFeeQuery('SRC', 'TGT'), + { wrapper }, + ) + + await waitFor(() => { + expect(result.current.isPending).toBe(false) + }) + expect(result.current.feeAlgos?.toString()).toBe('0.002') + expect(mockPayment).toHaveBeenCalledWith( + expect.objectContaining({ + sender: 'SRC', + receiver: 'SRC', + rekeyTo: 'TGT', + }), + ) + }) + + it('falls back to MIN_TXN_FEE when the built transaction has no fee', async () => { + // AlgoKit may leave `fee` undefined in some constructs; the optional + // chain falls back to the network minimum (1000 microAlgo → 0.001 ALGO). + mockPayment.mockResolvedValueOnce({ fee: undefined }) + const { wrapper } = buildWrapper() + + const { result } = renderHook( + () => useRekeyTransactionFeeQuery('SRC', 'TGT'), + { wrapper }, + ) + + await waitFor(() => { + expect(result.current.isPending).toBe(false) + }) + expect(result.current.feeAlgos?.toString()).toBe('0.001') + }) + + it('does not run the query when sourceAddress is empty', async () => { + const { wrapper } = buildWrapper() + const { result } = renderHook( + () => useRekeyTransactionFeeQuery('', 'TGT'), + { wrapper }, + ) + + // No fetch should have been queued. + expect(mockPayment).not.toHaveBeenCalled() + expect(result.current.feeAlgos).toBeUndefined() + }) + + it('does not run the query when rekeyToAddress is empty', async () => { + const { wrapper } = buildWrapper() + const { result } = renderHook( + () => useRekeyTransactionFeeQuery('SRC', ''), + { wrapper }, + ) + + expect(mockPayment).not.toHaveBeenCalled() + expect(result.current.feeAlgos).toBeUndefined() + }) + + it('caches per network — a mainnet fee does not satisfy a testnet query', async () => { + // Same QueryClient across both renders — but the network change should + // produce a fresh fetch because network is part of the query key. + mockPayment + .mockResolvedValueOnce({ fee: 1000n }) + .mockResolvedValueOnce({ fee: 5000n }) + const { wrapper } = buildWrapper() + + const { result: mainnet } = renderHook( + () => useRekeyTransactionFeeQuery('SRC', 'TGT'), + { wrapper }, + ) + await waitFor(() => expect(mainnet.current.isPending).toBe(false)) + expect(mainnet.current.feeAlgos?.toString()).toBe('0.001') + + mockUseNetwork.mockReturnValue({ network: 'testnet' }) + const { result: testnet } = renderHook( + () => useRekeyTransactionFeeQuery('SRC', 'TGT'), + { wrapper }, + ) + await waitFor(() => expect(testnet.current.isPending).toBe(false)) + expect(testnet.current.feeAlgos?.toString()).toBe('0.005') + expect(mockPayment).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/walletconnect/src/hooks/__tests__/useWalletConnectHandlers.test.ts b/packages/walletconnect/src/hooks/__tests__/useWalletConnectHandlers.test.ts index 8710e59de..6af1a9ccf 100644 --- a/packages/walletconnect/src/hooks/__tests__/useWalletConnectHandlers.test.ts +++ b/packages/walletconnect/src/hooks/__tests__/useWalletConnectHandlers.test.ts @@ -100,7 +100,7 @@ vi.mock('@perawallet/wallet-core-accounts', () => ({ useSigningAccounts: vi.fn(() => [ { address: 'addr1', name: 'Account 1', type: 'standard' }, ]), - canSignWithAccount: vi.fn(() => true), + canSignWith: vi.fn(() => true), getAccountDisplayName: vi.fn((a: any) => a.name || a.address), isHardwareWalletAccount: vi.fn(() => false), })) diff --git a/packages/walletconnect/src/hooks/useWalletConnectHandlers.ts b/packages/walletconnect/src/hooks/useWalletConnectHandlers.ts index 2990515e7..17b3e02f4 100644 --- a/packages/walletconnect/src/hooks/useWalletConnectHandlers.ts +++ b/packages/walletconnect/src/hooks/useWalletConnectHandlers.ts @@ -51,7 +51,7 @@ import { import { MAX_DATA_SIGN_REQUESTS } from '../constants' import { arc60PayloadSchema } from '../schema' import { - canSignWithAccount, + canSignWith, isHardwareWalletAccount, useAllAccounts, useSigningAccounts, @@ -145,7 +145,7 @@ const validateDataSignRequest = ( const account = accounts.find( account => account.address === item.signer, ) - if (!account || !canSignWithAccount(account, accounts)) { + if (!account || !canSignWith(account, accounts)) { throw new WalletConnectInvalidSessionError('Invalid signer') } @@ -209,7 +209,7 @@ const validateArc60Request = ( throw new WalletConnectInvalidSessionError('Invalid signer') } const account = accounts.find(a => a.address === signer) - if (!account || !canSignWithAccount(account, accounts)) { + if (!account || !canSignWith(account, accounts)) { throw new WalletConnectInvalidSessionError('Invalid signer') } if (isHardwareWalletAccount(account)) {