diff --git a/apps/mobile/ios/Podfile.lock b/apps/mobile/ios/Podfile.lock index 9c4732c00..d3a836821 100644 --- a/apps/mobile/ios/Podfile.lock +++ b/apps/mobile/ios/Podfile.lock @@ -2933,6 +2933,34 @@ PODS: - React-perflogger (= 0.83.0) - React-utils (= 0.83.0) - SocketRocket + - ReactNativeBiometrics (0.11.0): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga - RNBootSplash (6.3.11): - boost - DoubleConversion @@ -3538,6 +3566,7 @@ DEPENDENCIES: - ReactAppDependencyProvider (from `build/generated/ios/ReactAppDependencyProvider`) - ReactCodegen (from `build/generated/ios/ReactCodegen`) - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) + - "ReactNativeBiometrics (from `../node_modules/@sbaiahmed1/react-native-biometrics`)" - RNBootSplash (from `../node_modules/react-native-bootsplash`) - "RNCClipboard (from `../node_modules/@react-native-clipboard/clipboard`)" - RNDeviceInfo (from `../node_modules/react-native-device-info`) @@ -3766,6 +3795,8 @@ EXTERNAL SOURCES: :path: build/generated/ios/ReactCodegen ReactCommon: :path: "../node_modules/react-native/ReactCommon" + ReactNativeBiometrics: + :path: "../node_modules/@sbaiahmed1/react-native-biometrics" RNBootSplash: :path: "../node_modules/react-native-bootsplash" RNCClipboard: @@ -3831,7 +3862,7 @@ SPEC CHECKSUMS: GoogleAppMeasurement: 3bf40aff49a601af5da1c3345702fcb4991d35ee GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 - hermes-engine: 617e25b15d2a434c37e627dfb6dff32a86fea6c2 + hermes-engine: c3de7a5fb8b174d0371f1dd0bb649d3ac1a4df4e lottie-ios: a881093fab623c467d3bce374367755c272bdd59 lottie-react-native: 86fa7488b1476563f41071244aa73d5b738faf19 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 @@ -3918,6 +3949,7 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: ebcf3a78dc1bcdf054c9e8d309244bade6b31568 ReactCodegen: 37f746e57fa4a5109edd15a2336009ad72e80f8c ReactCommon: 424cc34cf5055d69a3dcf02f3436481afb8b0f6f + ReactNativeBiometrics: 70a5300cf5272c127e700417e3cd4cba4bf05cac RNBootSplash: 98a34471966945e8dacd1da8f0c2f87cbc2e2df7 RNCClipboard: 4b58c780f63676367640f23c8e114e9bd0cf86ac RNDeviceInfo: bcce8752b5043a623fe3c26789679b473f705d3c @@ -3942,4 +3974,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: f8175fd2f0a1ebad4218ad393842b4d06245a8e2 -COCOAPODS: 1.16.2 +COCOAPODS: 1.15.2 diff --git a/apps/mobile/src/i18n/locales/en.json b/apps/mobile/src/i18n/locales/en.json index 50c88d0e9..cd5b3142a 100644 --- a/apps/mobile/src/i18n/locales/en.json +++ b/apps/mobile/src/i18n/locales/en.json @@ -276,7 +276,7 @@ "onboarding": { "import_options": { "title": "Select your Mnemonic type", - "universal_wallet": { + "hd_wallet": { "title": "Universal Wallet", "description": "Wallet that lets you derive new accounts, all using the same mnemonic", "chip": "new" @@ -294,6 +294,7 @@ "button": "Recover an Algorand Wallet" }, "import_account": { + "title": "Enter your Recovery Passphrase", "importing": "Importing wallet...", "button": "Import Wallet", "failed_title": "Import failed", diff --git a/apps/mobile/src/modules/onboarding/routes/index.tsx b/apps/mobile/src/modules/onboarding/routes/index.tsx index 4266ddbe3..7d1cc066d 100644 --- a/apps/mobile/src/modules/onboarding/routes/index.tsx +++ b/apps/mobile/src/modules/onboarding/routes/index.tsx @@ -76,6 +76,7 @@ export const OnboardingStackNavigator = () => { name='ImportInfo' options={{ headerShown: false, + title: '', }} layout={safeAreaLayout} component={ImportInfoScreenWithErrorBoundary} @@ -83,8 +84,7 @@ export const OnboardingStackNavigator = () => { { { const { theme } = useTheme() - const navigation = useNavigation>() - const styles = useStyles() - const importAccount = useImportAccount() - const { showToast } = useToast() - - const [words, setWords] = useState(new Array(NUM_WORDS).fill('')) - const [focused, setFocused] = useState(0) - const [canImport, setCanImport] = useState(false) - const [processing, setProcessing] = useState(false) - const { t } = useLanguage() - - const updateWord = (word: string, index: number) => { - const splitWords = word.split('\n') - - if (splitWords.length === NUM_WORDS) { - setWords(splitWords) - } else { - setWords(prev => { - prev[index] = word.trim() - return [...prev] - }) - } - - if (words.every(w => w.length)) { - setCanImport(true) - } - } - - const handleImportAccount = () => { - setProcessing(true) - setTimeout(async () => { - try { - await importAccount({ mnemonic: words.join(' ') }) - goToHome() - } catch { - showToast({ - title: t('onboarding.import_account.failed_title'), - body: t('onboarding.import_account.failed_body'), - type: 'error', - }) - } finally { - setProcessing(false) - } - }, 0) - } + const insets = useSafeAreaInsets() + const navigation = useAppNavigation() + const { + words, + focused, + setFocused, + canImport, + processing, + updateWord, + handleImportAccount, + mnemonicLength, + t, + isKeyboardVisible, + keyboardHeight, + } = useImportAccountScreen() + const styles = useStyles({ insets, isKeyboardVisible, keyboardHeight }) - const goToHome = () => { - navigation.replace('TabBar', { - screen: 'Home', - }) - } + const wordsPerColumn = Math.ceil(mnemonicLength / 2) return ( - <> - - + + + + } + /> + + + + {t('onboarding.import_account.title')} + {[0, 1].map(column => { - const columnOffset = 12 * column + const columnOffset = wordsPerColumn * column return ( {words - .slice(columnOffset, columnOffset + 12) + .slice( + columnOffset, + columnOffset + wordsPerColumn, + ) .map((word, index) => { const offsetIndex = index + columnOffset @@ -168,15 +152,18 @@ export const ImportAccountScreen = () => { ) })} + + + - + + { color={theme.colors.linkPrimary} /> - + ) } diff --git a/apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/styles.ts b/apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/styles.ts index 036e10ad6..4c8d117af 100644 --- a/apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/styles.ts +++ b/apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/styles.ts @@ -11,88 +11,109 @@ */ import { makeStyles } from '@rneui/themed' +import { EdgeInsets } from 'react-native-safe-area-context' -export const useStyles = makeStyles(theme => { - return { - mainContainer: { - flex: 1, - }, - helperText: { - color: theme.colors.textGray, - paddingBottom: theme.spacing['3xl'], - }, - walletNameContainer: { - backgroundColor: theme.colors.layerGrayLighter, - borderRadius: theme.spacing.sm, - paddingVertical: theme.spacing.md, - flexDirection: 'row', - flexWrap: 'nowrap', - alignItems: 'center', - justifyContent: 'center', - gap: theme.spacing.sm, - }, - nameText: { - color: theme.colors.textGray, - alignSelf: 'center', - }, - finishButton: { - marginHorizontal: theme.spacing.xl, - }, - spacer: { - flexGrow: 1, - }, - wordContainer: { - flexDirection: 'row', - justifyContent: 'space-between', - marginBottom: theme.spacing.lg, - }, - column: { - width: '47%', - }, - scrollView: { - paddingBottom: theme.spacing.lg, - }, - inputContainerRow: { - marginTop: theme.spacing.sm, - flexDirection: 'row', - gap: theme.spacing.sm, - alignItems: 'center', - }, - label: { - color: theme.colors.textGray, - }, - focusedLabel: { - color: theme.colors.textMain, - }, - inputOuterContainer: { - flexShrink: 1, - }, - inputContainer: { - backgroundColor: theme.colors.background, - borderBottomWidth: 1, - borderBottomColor: theme.colors.layerGray, - flexShrink: 1, - }, - focusedInputContainer: { - backgroundColor: theme.colors.background, - borderBottomWidth: 1, - borderBottomColor: theme.colors.textMain, - flexShrink: 1, - }, - input: { - flexShrink: 1, - backgroundColor: 'transparent', - }, - overlayBackdrop: { - backgroundColor: 'rgba(52, 52, 52, 0.8)', - }, - overlay: { - padding: theme.spacing.xl, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: theme.colors.layerGray, - borderRadius: theme.spacing.lg, - gap: theme.spacing.lg, - }, - } -}) +type StyleProps = { + insets: EdgeInsets + isKeyboardVisible: boolean + keyboardHeight: number +} + +export const useStyles = makeStyles( + (theme, { insets, isKeyboardVisible, keyboardHeight }: StyleProps) => { + return { + mainContainer: { + flex: 1, + backgroundColor: theme.colors.background, + }, + helperText: { + color: theme.colors.textGray, + paddingBottom: theme.spacing['3xl'], + }, + walletNameContainer: { + backgroundColor: theme.colors.layerGrayLighter, + borderRadius: theme.spacing.sm, + paddingVertical: theme.spacing.md, + flexDirection: 'row', + flexWrap: 'nowrap', + alignItems: 'center', + justifyContent: 'center', + gap: theme.spacing.sm, + }, + nameText: { + color: theme.colors.textGray, + alignSelf: 'center', + }, + finishButton: { + marginHorizontal: 0, // Footers padding handles this + }, + spacer: { + flexGrow: 1, + }, + wordContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: theme.spacing.lg, + }, + column: { + width: '47%', + }, + scrollContainer: { + flex: 1, + }, + scrollView: { + paddingHorizontal: theme.spacing.xl, + }, + footer: { + backgroundColor: theme.colors.background, + paddingHorizontal: theme.spacing.xl, + paddingTop: theme.spacing.md, + paddingBottom: isKeyboardVisible + ? keyboardHeight + theme.spacing.lg - insets.bottom + : theme.spacing.lg, + }, + inputContainerRow: { + marginTop: theme.spacing.sm, + flexDirection: 'row', + gap: theme.spacing.sm, + alignItems: 'center', + }, + label: { + color: theme.colors.textGray, + }, + focusedLabel: { + color: theme.colors.textMain, + }, + inputOuterContainer: { + flexShrink: 1, + }, + inputContainer: { + backgroundColor: theme.colors.background, + borderBottomWidth: 1, + borderBottomColor: theme.colors.layerGray, + flexShrink: 1, + }, + focusedInputContainer: { + backgroundColor: theme.colors.background, + borderBottomWidth: 1, + borderBottomColor: theme.colors.textMain, + flexShrink: 1, + }, + input: { + flexShrink: 1, + backgroundColor: 'transparent', + }, + overlayBackdrop: { + backgroundColor: 'rgba(52, 52, 52, 0.8)', + }, + overlay: { + padding: theme.spacing.xl, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: theme.colors.layerGray, + borderRadius: theme.spacing.lg, + gap: theme.spacing.lg, + }, + } + }, +) diff --git a/apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/useImportAccountScreen.ts b/apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/useImportAccountScreen.ts new file mode 100644 index 000000000..068f230a3 --- /dev/null +++ b/apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/useImportAccountScreen.ts @@ -0,0 +1,144 @@ +/* + 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 { useState, useCallback, useMemo, useEffect } from 'react' +import { Keyboard, Platform } from 'react-native' +import { RouteProp, useRoute } from '@react-navigation/native' +import { OnboardingStackParamList } from '../../routes/types' +import { + useImportAccount, + ImportAccountType, +} from '@perawallet/wallet-core-accounts' +import { useToast } from '@hooks/useToast' +import { useLanguage } from '@hooks/useLanguage' +import { useAppNavigation } from '@hooks/useAppNavigation' + +const MNEMONIC_LENGTH_MAP: Record = { + hdWallet: 24, + algo25: 25, +} + +export type UseImportAccountScreenResult = { + words: string[] + focused: number + setFocused: (index: number) => void + canImport: boolean + processing: boolean + updateWord: (word: string, index: number) => void + handleImportAccount: () => void + mnemonicLength: number + t: (key: string) => string + isKeyboardVisible: boolean + keyboardHeight: number +} + +export function useImportAccountScreen(): UseImportAccountScreenResult { + const { + params: { accountType }, + } = useRoute>() + const navigation = useAppNavigation() + const importAccount = useImportAccount() + const { showToast } = useToast() + const { t } = useLanguage() + + const [isKeyboardVisible, setIsKeyboardVisible] = useState(false) + const [keyboardHeight, setKeyboardHeight] = useState(0) + + useEffect(() => { + const showEvent = + Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow' + const hideEvent = + Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide' + + const showSubscription = Keyboard.addListener(showEvent, e => { + setIsKeyboardVisible(true) + setKeyboardHeight(e.endCoordinates.height) + }) + const hideSubscription = Keyboard.addListener(hideEvent, () => { + setIsKeyboardVisible(false) + setKeyboardHeight(0) + }) + + return () => { + showSubscription.remove() + hideSubscription.remove() + } + }, []) + + const mnemonicLength = MNEMONIC_LENGTH_MAP[accountType] + + const [words, setWords] = useState( + new Array(mnemonicLength).fill(''), + ) + const [focused, setFocused] = useState(0) + const [processing, setProcessing] = useState(false) + + const canImport = useMemo(() => words.every(w => w.length > 0), [words]) + + const updateWord = useCallback( + (word: string, index: number) => { + const splitWords = word.split('\n') + + if (splitWords.length === mnemonicLength) { + setWords(splitWords) + } else { + setWords(prev => { + const next = [...prev] + next[index] = word.trim() + return next + }) + } + }, + [mnemonicLength], + ) + + const goToHome = useCallback(() => { + navigation.replace('TabBar', { + screen: 'Home', + }) + }, [navigation]) + + const handleImportAccount = useCallback(() => { + setProcessing(true) + setTimeout(async () => { + try { + await importAccount({ + mnemonic: words.join(' '), + type: accountType, + }) + goToHome() + } catch { + showToast({ + title: t('onboarding.import_account.failed_title'), + body: t('onboarding.import_account.failed_body'), + type: 'error', + }) + } finally { + setProcessing(false) + } + }, 0) + }, [importAccount, words, goToHome, showToast, t]) + + return { + words, + focused, + setFocused, + canImport, + processing, + updateWord, + handleImportAccount, + mnemonicLength, + t, + isKeyboardVisible, + keyboardHeight, + } +} diff --git a/apps/mobile/src/modules/onboarding/screens/ImportInfoScreen/__tests__/ImportInfoScreen.spec.tsx b/apps/mobile/src/modules/onboarding/screens/ImportInfoScreen/__tests__/ImportInfoScreen.spec.tsx index 33dc3b423..4f24cc8a4 100644 --- a/apps/mobile/src/modules/onboarding/screens/ImportInfoScreen/__tests__/ImportInfoScreen.spec.tsx +++ b/apps/mobile/src/modules/onboarding/screens/ImportInfoScreen/__tests__/ImportInfoScreen.spec.tsx @@ -81,6 +81,17 @@ vi.mock('@components/core', async () => { } }) +// Mock react-navigation +vi.mock('@react-navigation/native', async () => { + const actual = await vi.importActual('@react-navigation/native') + return { + ...actual, + useRoute: () => ({ + params: { accountType: 'hdWallet' }, + }), + } +}) + describe('ImportInfoScreen', () => { beforeEach(() => { vi.clearAllMocks() @@ -109,7 +120,9 @@ describe('ImportInfoScreen', () => { const recoverButton = screen.getByText('onboarding.import_info.button') fireEvent.click(recoverButton) - expect(mockPush).toHaveBeenCalledWith('ImportAccount') + expect(mockPush).toHaveBeenCalledWith('ImportAccount', { + accountType: 'hdWallet', + }) }) it('handles info button press', () => { diff --git a/apps/mobile/src/modules/onboarding/screens/ImportInfoScreen/useImportInfoScreen.ts b/apps/mobile/src/modules/onboarding/screens/ImportInfoScreen/useImportInfoScreen.ts index 0785830cf..3d3935f80 100644 --- a/apps/mobile/src/modules/onboarding/screens/ImportInfoScreen/useImportInfoScreen.ts +++ b/apps/mobile/src/modules/onboarding/screens/ImportInfoScreen/useImportInfoScreen.ts @@ -12,17 +12,22 @@ import { useCallback } from 'react' import { useAppNavigation } from '@hooks/useAppNavigation' +import { RouteProp, useRoute } from '@react-navigation/native' +import { OnboardingStackParamList } from '../../routes/types' export const useImportInfoScreen = () => { const navigation = useAppNavigation() + const { + params: { accountType }, + } = useRoute>() const handleBackPress = useCallback(() => { navigation.goBack() }, [navigation]) const handleRecoverPress = useCallback(() => { - navigation.push('ImportAccount') - }, [navigation]) + navigation.push('ImportAccount', { accountType }) + }, [navigation, accountType]) const handleInfoPress = useCallback(() => { // Requirements don't specify what this does, but it's an icon in the right diff --git a/apps/mobile/src/modules/onboarding/screens/NameAccountScreen/NameAccountScreen.tsx b/apps/mobile/src/modules/onboarding/screens/NameAccountScreen/NameAccountScreen.tsx index fab44717e..e4f93f2fc 100644 --- a/apps/mobile/src/modules/onboarding/screens/NameAccountScreen/NameAccountScreen.tsx +++ b/apps/mobile/src/modules/onboarding/screens/NameAccountScreen/NameAccountScreen.tsx @@ -17,17 +17,21 @@ import { PWInput, PWOverlay, PWText, + PWToolbar, + PWTouchableOpacity, PWView, } from '@components/core' import { useTheme } from '@rneui/themed' import { ActivityIndicator, KeyboardAvoidingView, Platform } from 'react-native' import { useLanguage } from '@hooks/useLanguage' import { useNameAccountScreen } from './useNameAccountScreen' +import { useAppNavigation } from '@hooks/useAppNavigation' export const NameAccountScreen = () => { const styles = useStyles() const { theme } = useTheme() const { t } = useLanguage() + const navigation = useAppNavigation() const { walletDisplay, @@ -42,47 +46,58 @@ export const NameAccountScreen = () => { style={styles.mainContainer} behavior={Platform.OS === 'ios' ? 'padding' : 'height'} > - - - {t('onboarding.name_account.title')} - - - {t('onboarding.name_account.description')} - - - - + + + } + /> + + + + + {t('onboarding.name_account.title')} + + + {t('onboarding.name_account.description')} + + + + + + {t('onboarding.name_account.wallet_label', { + count: numWallets + 1, + })} + + + + + - - {t('onboarding.name_account.wallet_label', { - count: numWallets + 1, - })} - - - - + { return { mainContainer: { + flex: 1, + backgroundColor: theme.colors.background, + }, + content: { flex: 1, paddingHorizontal: theme.spacing.xl, }, diff --git a/apps/mobile/src/modules/onboarding/screens/OnboardingScreen/ImportOptionsBottomSheet/ImportOptionsBottomSheet.tsx b/apps/mobile/src/modules/onboarding/screens/OnboardingScreen/ImportOptionsBottomSheet/ImportOptionsBottomSheet.tsx index 2e6629e7f..452e77557 100644 --- a/apps/mobile/src/modules/onboarding/screens/OnboardingScreen/ImportOptionsBottomSheet/ImportOptionsBottomSheet.tsx +++ b/apps/mobile/src/modules/onboarding/screens/OnboardingScreen/ImportOptionsBottomSheet/ImportOptionsBottomSheet.tsx @@ -25,14 +25,14 @@ import { useTranslation } from 'react-i18next' export type ImportOptionsBottomSheetProps = { isVisible: boolean onClose: () => void - onUniversalWalletPress: () => void + onHDWalletPress: () => void onAlgo25Press: () => void } export const ImportOptionsBottomSheet = ({ isVisible, onClose, - onUniversalWalletPress, + onHDWalletPress, onAlgo25Press, }: ImportOptionsBottomSheetProps) => { const styles = useStyles() @@ -62,7 +62,7 @@ export const ImportOptionsBottomSheet = ({ @@ -70,12 +70,12 @@ export const ImportOptionsBottomSheet = ({ {t( - 'onboarding.import_options.universal_wallet.title', + 'onboarding.import_options.hd_wallet.title', )} @@ -85,7 +85,7 @@ export const ImportOptionsBottomSheet = ({ style={styles.optionBody} > {t( - 'onboarding.import_options.universal_wallet.description', + 'onboarding.import_options.hd_wallet.description', )} diff --git a/apps/mobile/src/modules/onboarding/screens/OnboardingScreen/ImportOptionsBottomSheet/__tests__/ImportOptionsBottomSheet.spec.tsx b/apps/mobile/src/modules/onboarding/screens/OnboardingScreen/ImportOptionsBottomSheet/__tests__/ImportOptionsBottomSheet.spec.tsx index ef7337d79..c4f86fdcb 100644 --- a/apps/mobile/src/modules/onboarding/screens/OnboardingScreen/ImportOptionsBottomSheet/__tests__/ImportOptionsBottomSheet.spec.tsx +++ b/apps/mobile/src/modules/onboarding/screens/OnboardingScreen/ImportOptionsBottomSheet/__tests__/ImportOptionsBottomSheet.spec.tsx @@ -25,7 +25,7 @@ describe('ImportOptionsBottomSheet', () => { const defaultProps = { isVisible: true, onClose: vi.fn(), - onUniversalWalletPress: vi.fn(), + onHDWalletPress: vi.fn(), onAlgo25Press: vi.fn(), } @@ -34,17 +34,13 @@ describe('ImportOptionsBottomSheet', () => { expect(screen.getByText('onboarding.import_options.title')).toBeTruthy() expect( - screen.getByText( - 'onboarding.import_options.universal_wallet.title', - ), + screen.getByText('onboarding.import_options.hd_wallet.title'), ).toBeTruthy() expect( screen.getByText('onboarding.import_options.algo25.title'), ).toBeTruthy() expect( - screen.getByText( - 'onboarding.import_options.universal_wallet.description', - ), + screen.getByText('onboarding.import_options.hd_wallet.description'), ).toBeTruthy() expect( screen.getByText('onboarding.import_options.algo25.description'), @@ -78,21 +74,19 @@ describe('ImportOptionsBottomSheet', () => { expect(onClose).toHaveBeenCalledTimes(1) }) - it('calls onUniversalWalletPress when Universal Wallet option is pressed', () => { - const onUniversalWalletPress = vi.fn() + it('calls onHDWalletPress when Universal Wallet option is pressed', () => { + const onHDWalletPress = vi.fn() render( , ) fireEvent.click( - screen.getByText( - 'onboarding.import_options.universal_wallet.title', - ), + screen.getByText('onboarding.import_options.hd_wallet.title'), ) - expect(onUniversalWalletPress).toHaveBeenCalledTimes(1) + expect(onHDWalletPress).toHaveBeenCalledTimes(1) }) it('calls onAlgo25Press when ALGO25 option is pressed', () => { diff --git a/apps/mobile/src/modules/onboarding/screens/OnboardingScreen/OnboardingScreen.tsx b/apps/mobile/src/modules/onboarding/screens/OnboardingScreen/OnboardingScreen.tsx index 452f91b96..cc67bb00b 100644 --- a/apps/mobile/src/modules/onboarding/screens/OnboardingScreen/OnboardingScreen.tsx +++ b/apps/mobile/src/modules/onboarding/screens/OnboardingScreen/OnboardingScreen.tsx @@ -32,7 +32,7 @@ export const OnboardingScreen = () => { handleCreateAccount, handleImportAccount, handleCloseImportOptions, - handleUniversalWalletPress, + handleHDWalletPress, handleAlgo25Press, } = useOnboardingScreen() @@ -105,7 +105,7 @@ export const OnboardingScreen = () => { diff --git a/apps/mobile/src/modules/onboarding/screens/OnboardingScreen/__tests__/OnboardingScreen.spec.tsx b/apps/mobile/src/modules/onboarding/screens/OnboardingScreen/__tests__/OnboardingScreen.spec.tsx index ee4afcf79..999295807 100644 --- a/apps/mobile/src/modules/onboarding/screens/OnboardingScreen/__tests__/OnboardingScreen.spec.tsx +++ b/apps/mobile/src/modules/onboarding/screens/OnboardingScreen/__tests__/OnboardingScreen.spec.tsx @@ -164,10 +164,12 @@ describe('OnboardingScreen', () => { // Click one of the options const universalWalletOption = screen.getByText( - 'onboarding.import_options.universal_wallet.title', + 'onboarding.import_options.hd_wallet.title', ) fireEvent.click(universalWalletOption) - expect(mockPush).toHaveBeenCalledWith('ImportInfo') + expect(mockPush).toHaveBeenCalledWith('ImportInfo', { + accountType: 'hdWallet', + }) }) }) diff --git a/apps/mobile/src/modules/onboarding/screens/OnboardingScreen/useOnboardingScreen.ts b/apps/mobile/src/modules/onboarding/screens/OnboardingScreen/useOnboardingScreen.ts index be0f57365..ffbfb2fc5 100644 --- a/apps/mobile/src/modules/onboarding/screens/OnboardingScreen/useOnboardingScreen.ts +++ b/apps/mobile/src/modules/onboarding/screens/OnboardingScreen/useOnboardingScreen.ts @@ -43,14 +43,14 @@ export const useOnboardingScreen = () => { navigation.push('NameAccount') }, [navigation]) - const handleUniversalWalletPress = useCallback(() => { + const handleHDWalletPress = useCallback(() => { closeImportOptions() - navigation.push('ImportInfo') + navigation.push('ImportInfo', { accountType: 'hdWallet' }) }, [closeImportOptions, navigation]) const handleAlgo25Press = useCallback(() => { closeImportOptions() - navigation.push('ImportInfo') + navigation.push('ImportInfo', { accountType: 'algo25' }) }, [closeImportOptions, navigation]) return { @@ -60,7 +60,7 @@ export const useOnboardingScreen = () => { handleCreateAccount, handleImportAccount: openImportOptions, handleCloseImportOptions: closeImportOptions, - handleUniversalWalletPress, + handleHDWalletPress, handleAlgo25Press, } } diff --git a/packages/accounts/package.json b/packages/accounts/package.json index ba36c7f34..ff8eaeb8f 100644 --- a/packages/accounts/package.json +++ b/packages/accounts/package.json @@ -26,6 +26,8 @@ "@perawallet/wallet-core-shared": "workspace:*", "@perawallet/wallet-core-kms": "workspace:*", "@algorandfoundation/xhd-wallet-api": "catalog:", + "@algorandfoundation/algokit-utils": "catalog:", + "tweetnacl": "^1.0.3", "@tanstack/react-query": "catalog:", "bip39": "catalog:", "decimal.js": "catalog:", diff --git a/packages/accounts/src/__tests__/utils.test.ts b/packages/accounts/src/__tests__/utils.test.ts index c59f5b920..3834f9166 100644 --- a/packages/accounts/src/__tests__/utils.test.ts +++ b/packages/accounts/src/__tests__/utils.test.ts @@ -11,7 +11,6 @@ */ import { describe, test, expect } from 'vitest' -import type { WalletAccount } from '../models' import { canSignWithAccount, getAccountDisplayName, @@ -21,63 +20,92 @@ import { isMultisigAccount, isRekeyedAccount, isWatchAccount, + createHDWalletKeyDataFromMnemonic, + createAlgo25WalletKeyDataFromMnemonic, + getSeedFromMasterKey, } from '../utils' +import { vi } from 'vitest' + +vi.mock('@algorandfoundation/algokit-utils/algo25', () => ({ + seedFromMnemonic: vi.fn(() => new Uint8Array(32).fill(1)), +})) + +vi.mock('bip39', () => ({ + mnemonicToSeed: vi.fn(async () => Buffer.from(new Uint8Array(64).fill(2))), + mnemonicToEntropy: vi.fn(async () => 'test-entropy'), +})) + +vi.mock('tweetnacl', () => ({ + default: { + sign: { + keyPair: { + fromSeed: vi.fn(() => ({ + publicKey: new Uint8Array(32).fill(3), + })), + }, + }, + }, +})) + +vi.mock('@perawallet/wallet-core-blockchain', () => ({ + encodeAlgorandAddress: vi.fn(() => 'TEST_ADDRESS'), +})) describe('services/accounts/utils - getAccountDisplayName', () => { test('returns account name when present', () => { - const acc: WalletAccount = { + const acc = { id: '1', type: 'hdWallet', address: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', name: 'Named', canSign: true, - } + } as any expect(getAccountDisplayName(acc)).toEqual('Named') }) test('returns "No Address Found" when address is missing or empty', () => { - const acc: WalletAccount = { + const acc = { id: '2', type: 'hdWallet', address: '', canSign: false, - } + } as any expect(getAccountDisplayName(acc)).toEqual('No Address Found') }) test('returns address unchanged when length <= 11', () => { - const acc1: WalletAccount = { + const acc1 = { id: '3', type: 'hdWallet', address: 'SHORT', canSign: true, - } + } as any expect(getAccountDisplayName(acc1)).toEqual('SHORT') - const acc2: WalletAccount = { + const acc2 = { id: '4', type: 'hdWallet', address: 'ABCDEFGHIJK', canSign: true, - } + } as any expect(getAccountDisplayName(acc2)).toEqual('ABCDEFGHIJK') }) test('truncates long addresses to 5 prefix and suffix characters', () => { - const acc1: WalletAccount = { + const acc1 = { id: '5', type: 'hdWallet', address: 'ABCDEFGHIJKL', canSign: true, - } + } as any expect(getAccountDisplayName(acc1)).toEqual('ABCDE...HIJKL') - const acc2: WalletAccount = { + const acc2 = { id: '6', type: 'hdWallet', address: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', canSign: true, - } + } as any expect(getAccountDisplayName(acc2)).toEqual('ABCDE...VWXYZ') }) @@ -87,12 +115,12 @@ describe('services/accounts/utils - getAccountDisplayName', () => { }) describe('services/accounts/utils - account type checks', () => { - const baseAccount: WalletAccount = { + const baseAccount = { id: '1', type: 'hdWallet', address: 'ADDR1', canSign: true, - } + } as any test('isHDWalletAccount returns true if type is hdWallet', () => { expect(isHDWalletAccount(baseAccount)).toBe(true) @@ -100,7 +128,7 @@ describe('services/accounts/utils - account type checks', () => { isHDWalletAccount({ ...baseAccount, type: 'algo25', - }), + } as any), ).toBe(false) }) @@ -111,14 +139,14 @@ describe('services/accounts/utils - account type checks', () => { ...baseAccount, type: 'hardware', hardwareDetails: { manufacturer: 'ledger' }, - }), + } as any), ).toBe(true) expect( isLedgerAccount({ ...baseAccount, type: 'hardware', hardwareDetails: { manufacturer: 'other' as any }, - }), + } as any), ).toBe(false) }) @@ -128,7 +156,7 @@ describe('services/accounts/utils - account type checks', () => { isRekeyedAccount({ ...baseAccount, rekeyAddress: 'ADDR2', - }), + } as any), ).toBe(true) }) @@ -138,19 +166,19 @@ describe('services/accounts/utils - account type checks', () => { isAlgo25Account({ ...baseAccount, type: 'algo25', - }), + } as any), ).toBe(true) expect( isAlgo25Account({ ...baseAccount, type: 'hdWallet', - }), + } as any), ).toBe(false) expect( isAlgo25Account({ ...baseAccount, type: 'watch', - }), + } as any), ).toBe(false) }) @@ -160,7 +188,7 @@ describe('services/accounts/utils - account type checks', () => { isWatchAccount({ ...baseAccount, type: 'watch', - }), + } as any), ).toBe(true) }) @@ -170,7 +198,7 @@ describe('services/accounts/utils - account type checks', () => { isMultisigAccount({ ...baseAccount, type: 'multisig', - }), + } as any), ).toBe(true) }) @@ -180,7 +208,54 @@ describe('services/accounts/utils - account type checks', () => { canSignWithAccount({ ...baseAccount, canSign: false, - }), + } as any), ).toBe(false) }) }) + +describe('services/accounts/utils - createHDWalletKeyDataFromMnemonic', () => { + test('creates key pair from valid mnemonic', async () => { + const mnemonic = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art' + const result = await createHDWalletKeyDataFromMnemonic(mnemonic) + + expect(result.seed).toBeInstanceOf(Buffer) + expect(result.seed.length).toBe(64) // BIP39 seed is 512 bits + expect(result.entropy).toBeDefined() + expect(result.type).toBe('hdwallet-root-key') + }) +}) + +describe('services/accounts/utils - createAlgo25WalletKeyDataFromMnemonic', () => { + test('creates key pair from valid mnemonic', async () => { + const mnemonic = + 'since theory average article fly finger table squirrel music degree arrest shallow unit medal update elevator snap code tip body switch mirror page able total' + const result = await createAlgo25WalletKeyDataFromMnemonic(mnemonic) + + expect(result.seed).toBeInstanceOf(Buffer) + expect(result.seed.length).toBe(32) // Algo25 seed is 256 bits + expect(result.entropy).toBeDefined() + expect(result.publicKey).toBeDefined() + expect(result.type).toBe('algo25-key') + }) +}) + +describe('services/accounts/utils - getSeedFromMasterKey', () => { + test('obtains seed from JSON stringified master key data', () => { + const masterKey = { + seed: Buffer.from('test-seed').toString('base64'), + entropy: 'test-entropy', + } + const keyData = new TextEncoder().encode(JSON.stringify(masterKey)) + const seed = getSeedFromMasterKey(keyData) + + expect(seed).toEqual(Buffer.from('test-seed')) + }) + + test('obtains seed from raw master key data', () => { + const keyData = new Uint8Array([1, 2, 3, 4]) + const seed = getSeedFromMasterKey(keyData) + + expect(seed).toEqual(Buffer.from([1, 2, 3, 4])) + }) +}) diff --git a/packages/accounts/src/hooks/__tests__/useImportAccount.test.ts b/packages/accounts/src/hooks/__tests__/useImportAccount.test.ts index 338aecce1..9afd653cb 100644 --- a/packages/accounts/src/hooks/__tests__/useImportAccount.test.ts +++ b/packages/accounts/src/hooks/__tests__/useImportAccount.test.ts @@ -50,6 +50,38 @@ const bip39Spies = vi.hoisted(() => ({ })) vi.mock('bip39', () => bip39Spies) +const algo25Spies = vi.hoisted(() => ({ + seedFromMnemonic: vi.fn(() => new Uint8Array(32).fill(3)), +})) +vi.mock('@algorandfoundation/algokit-utils/algo25', () => ({ + seedFromMnemonic: algo25Spies.seedFromMnemonic, +})) + +const naclSpies = vi.hoisted(() => ({ + sign: { + keyPair: { + fromSeed: vi.fn(() => ({ + publicKey: new Uint8Array(32).fill(4), + secretKey: new Uint8Array(64).fill(5), + })), + }, + }, +})) +vi.mock('tweetnacl', () => ({ + default: naclSpies, +})) + +vi.mock('@perawallet/wallet-core-blockchain', async () => { + return { + encodeAlgorandAddress: vi.fn((address: Uint8Array) => + Buffer.from(address).toString('base64'), + ), + useTransactionEncoder: vi.fn(() => ({ + encodeTransaction: vi.fn(), + })), + } +}) + vi.mock('@perawallet/wallet-core-shared', async () => { const actual = await vi.importActual< typeof import('@perawallet/wallet-core-shared') @@ -81,6 +113,45 @@ vi.mock('@perawallet/wallet-core-platform-integration', async () => { } }) +const kmsSpies = vi.hoisted(() => { + const keys = new Map() + return { + saveKey: vi.fn(async (key: any) => { + keys.set(key.id, key) + return key + }), + getKey: vi.fn((id: string) => keys.get(id) || null), + executeWithKey: vi.fn( + async (_id: string, _domain: string, handler: any) => { + const dummyData = JSON.stringify({ + seed: Buffer.from('seed').toString('base64'), + entropy: 'entropy', + }) + return handler(new TextEncoder().encode(dummyData)) + }, + ), + } +}) + +vi.mock('@perawallet/wallet-core-kms', async () => { + const actual = await vi.importActual< + typeof import('@perawallet/wallet-core-kms') + >('@perawallet/wallet-core-kms') + return { + ...actual, + useKMS: vi.fn(() => ({ + saveKey: kmsSpies.saveKey, + getKey: kmsSpies.getKey, + deleteKey: vi.fn(), + getPrivateData: vi.fn(), + keys: new Map(), + })), + useWithKey: vi.fn(() => ({ + executeWithKey: kmsSpies.executeWithKey, + })), + } +}) + vi.mock('../../store', async () => { const actual = await vi.importActual('../../store') @@ -133,18 +204,24 @@ describe('useImportAccount', () => { let imported: any await act(async () => { - imported = await result.current({ mnemonic: 'test mnemonic' }) + imported = await result.current({ + mnemonic: 'test mnemonic', + type: 'hdWallet', + }) }) expect(imported.address).toBeTruthy() expect(imported.id).toBeTruthy() - // Verify storage calls - only root key saved - expect(dummySecure.setItem).toHaveBeenCalledTimes(1) - expect(dummySecure.setItem).toHaveBeenNthCalledWith( + // Verify kms calls + expect(kmsSpies.saveKey).toHaveBeenCalledTimes(1) + expect(kmsSpies.saveKey).toHaveBeenNthCalledWith( 1, - 'WALLET1', - expect.anything(), // Root key data (JSON string as Uint8Array) + expect.objectContaining({ + id: 'WALLET1', + type: 'hdwallet-root-key', + }), + expect.anything(), ) expect(useAccountsStore.getState().accounts).toHaveLength(1) }) @@ -171,14 +248,17 @@ describe('useImportAccount', () => { await act(async () => { await expect( - result.current({ mnemonic: 'invalid mnemonic' }), + result.current({ + mnemonic: 'invalid mnemonic', + type: 'hdWallet', + }), ).rejects.toThrow('Invalid mnemonic') }) }) test('throws error when secure storage setItem fails for root key', async () => { const dummySecure = { - setItem: vi.fn().mockRejectedValueOnce(new Error('Storage full')), // First call for root key fails + setItem: vi.fn(async () => {}), getItem: vi.fn(async () => null), removeItem: vi.fn(async () => {}), authenticate: vi.fn(async () => true), @@ -190,12 +270,16 @@ describe('useImportAccount', () => { }) uuidSpies.v7.mockImplementationOnce(() => 'WALLET1') + kmsSpies.saveKey.mockRejectedValueOnce(new Error('Storage full')) const { result } = renderHook(() => useImportAccount()) await act(async () => { await expect( - result.current({ mnemonic: 'test mnemonic' }), + result.current({ + mnemonic: 'test mnemonic', + type: 'hdWallet', + }), ).rejects.toThrow('Storage full') }) }) @@ -222,7 +306,10 @@ describe('useImportAccount', () => { await act(async () => { await expect( - result.current({ mnemonic: 'test mnemonic' }), + result.current({ + mnemonic: 'test mnemonic', + type: 'hdWallet', + }), ).rejects.toThrow('Invalid entropy') }) }) @@ -261,16 +348,19 @@ describe('useImportAccount', () => { imported = await result.current({ walletId: 'CUSTOM_WALLET', mnemonic: 'test mnemonic', + type: 'hdWallet', }) }) expect(imported).toBeTruthy() expect(imported.hdWalletDetails?.walletId).toBe('CUSTOM_WALLET') - // Verify storage calls - only root key saved - expect(dummySecure.setItem).toHaveBeenCalledTimes(1) - expect(dummySecure.setItem).toHaveBeenNthCalledWith( + // Verify kms calls + expect(kmsSpies.saveKey).toHaveBeenCalledTimes(1) + expect(kmsSpies.saveKey).toHaveBeenNthCalledWith( 1, - 'CUSTOM_WALLET', + expect.objectContaining({ + id: 'CUSTOM_WALLET', + }), expect.anything(), ) }) @@ -305,8 +395,61 @@ describe('useImportAccount', () => { await act(async () => { await expect( - result.current({ mnemonic: 'test mnemonic' }), + result.current({ + mnemonic: 'test mnemonic', + type: 'hdWallet', + }), ).rejects.toThrow('Address generation failed') }) }) + + test('imports algo25 account and persists keys', async () => { + const storage = new Map() + const dummySecure = { + setItem: vi.fn(async (key, value) => { + storage.set(key, value) + }), + getItem: vi.fn(async key => storage.get(key) ?? null), + removeItem: vi.fn(async key => { + storage.delete(key) + }), + authenticate: vi.fn(async () => true), + } + + registerTestPlatform({ + keyValueStorage: new MemoryKeyValueStorage() as any, + secureStorage: dummySecure as any, + }) + + uuidSpies.v7 + .mockImplementationOnce(() => 'WALLET1') + .mockImplementationOnce(() => 'ACC1') + + const { result } = renderHook(() => useImportAccount()) + + let imported: any + await act(async () => { + imported = await result.current({ + mnemonic: 'test mnemonic', + type: 'algo25', + }) + }) + + expect(imported.address).toBeTruthy() + expect(imported.id).toBeTruthy() + expect(imported.type).toBe('algo25') + + // Verify kms calls + expect(kmsSpies.saveKey).toHaveBeenCalledTimes(1) + expect(kmsSpies.saveKey).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + id: 'WALLET1', + type: 'algo25-key', + publicKey: 'BAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQ=', // base64(fill(4)) + }), + expect.anything(), + ) + expect(useAccountsStore.getState().accounts).toHaveLength(1) + }) }) diff --git a/packages/accounts/src/hooks/useCreateAccount.ts b/packages/accounts/src/hooks/useCreateAccount.ts index 23532df28..052394d81 100644 --- a/packages/accounts/src/hooks/useCreateAccount.ts +++ b/packages/accounts/src/hooks/useCreateAccount.ts @@ -19,7 +19,7 @@ import { import { useAccountsStore } from '../store' import { useHDWallet } from './useHDWallet' import { v7 as uuidv7 } from 'uuid' -import { AccountTypes, WalletAccount } from '../models' +import { AccountTypes, WalletAccount, ImportAccountType } from '../models' import { BIP32DerivationType } from '@algorandfoundation/xhd-wallet-api' import { encodeAlgorandAddress } from '@perawallet/wallet-core-blockchain' import { @@ -46,14 +46,17 @@ export const useCreateAccount = () => { walletId, account, keyIndex, + type = 'hdWallet', }: { walletId?: string account: number keyIndex: number + type?: ImportAccountType }) => { const rootWalletId = walletId ?? uuidv7() //TODO dry this code - maybe create a useHDWalletKey hook and share with useImportAccount let rootKey = getKey(rootWalletId) + if (!rootKey) { const masterKey = await generateMasterKey() const keyData = { @@ -67,7 +70,10 @@ export const useCreateAccount = () => { privateDataStorageKey: '', domain: KEY_DOMAIN, createdAt: new Date(), - type: KeyType.HDWalletRootKey, + type: + type === 'hdWallet' + ? KeyType.HDWalletRootKey + : KeyType.Algo25Key, } as KeyPair rootKey = await saveKey( @@ -77,6 +83,30 @@ export const useCreateAccount = () => { masterKey.seed.fill(0) } + if (rootKey && rootKey.type === KeyType.Algo25Key) { + const newAccount: WalletAccount = { + id: uuidv7(), + address: rootKey.publicKey, + type: AccountTypes.algo25, + canSign: true, + keyPairId: rootKey.id, + } + + accounts.push(newAccount) + setAccounts([...accounts]) + + if (deviceID) { + updateDeviceOnBackend({ + deviceId: deviceID, + data: { + platform: deviceInfo.getDevicePlatform(), + accounts: accounts.map(a => a.address), + }, + }) + } + return newAccount + } + if (!rootKey?.id) { throw new NoHDWalletError(rootWalletId) } diff --git a/packages/accounts/src/hooks/useImportAccount.ts b/packages/accounts/src/hooks/useImportAccount.ts index b2c963631..649945c82 100644 --- a/packages/accounts/src/hooks/useImportAccount.ts +++ b/packages/accounts/src/hooks/useImportAccount.ts @@ -10,36 +10,46 @@ limitations under the License */ -import { useHDWallet } from './useHDWallet' import { v7 as uuidv7 } from 'uuid' -import { KeyType, useKMS } from '@perawallet/wallet-core-kms' +import { useKMS } from '@perawallet/wallet-core-kms' import { useCreateAccount } from './useCreateAccount' +import { ImportAccountType } from '../models' +import { + createHDWalletKeyDataFromMnemonic, + createAlgo25WalletKeyDataFromMnemonic, +} from '../utils' export const useImportAccount = () => { - const { generateMasterKey } = useHDWallet() const { saveKey } = useKMS() const createAccount = useCreateAccount() return async ({ walletId, mnemonic, + type, }: { walletId?: string mnemonic: string + type: ImportAccountType }) => { const rootWalletId = walletId ?? uuidv7() - const masterKey = await generateMasterKey(mnemonic) + + const keyData = await (type === 'hdWallet' + ? createHDWalletKeyDataFromMnemonic(mnemonic) + : createAlgo25WalletKeyDataFromMnemonic(mnemonic)) + const stringifiedObj = JSON.stringify({ - seed: masterKey.seed.toString('base64'), - entropy: masterKey.entropy, + seed: keyData.seed.toString('base64'), + entropy: keyData.entropy, }) const rootKeyPair = { id: rootWalletId, - publicKey: '', + publicKey: keyData.publicKey ?? '', privateDataStorageKey: rootWalletId, createdAt: new Date(), - type: KeyType.HDWalletRootKey, + type: keyData.type, } + await saveKey(rootKeyPair, Buffer.from(stringifiedObj)) //TODO: we currently just create the 0/0 account but we really should scan the blockchain @@ -49,6 +59,7 @@ export const useImportAccount = () => { walletId: rootWalletId, account: 0, keyIndex: 0, + type, }) return newAccount } diff --git a/packages/accounts/src/models/accounts.ts b/packages/accounts/src/models/accounts.ts index 676fb0a93..f05ecfbf8 100644 --- a/packages/accounts/src/models/accounts.ts +++ b/packages/accounts/src/models/accounts.ts @@ -28,6 +28,8 @@ export const AccountTypes = { export type AccountType = (typeof AccountTypes)[keyof typeof AccountTypes] +export type ImportAccountType = 'hdWallet' | 'algo25' + export type HDWalletDetails = { walletId: string account: number diff --git a/packages/accounts/src/utils.ts b/packages/accounts/src/utils.ts index 79222a3ba..2e1e5cb0e 100644 --- a/packages/accounts/src/utils.ts +++ b/packages/accounts/src/utils.ts @@ -20,6 +20,18 @@ import { WatchAccount, type WalletAccount, } from './models' +import { KeyType } from '@perawallet/wallet-core-kms' +import * as bip39 from 'bip39' +import { seedFromMnemonic } from '@algorandfoundation/algokit-utils/algo25' +import nacl from 'tweetnacl' +import { encodeAlgorandAddress } from '@perawallet/wallet-core-blockchain' + +export type MnemonicKeyData = { + seed: Buffer + entropy?: string + publicKey?: string + type: KeyType +} export const getAccountDisplayName = (account: WalletAccount | null) => { if (!account) return 'No Account' @@ -79,3 +91,30 @@ export const getSeedFromMasterKey = (keyData: Uint8Array) => { return Buffer.from(keyData) } } + +export const createHDWalletKeyDataFromMnemonic = async ( + mnemonic: string, +): Promise => { + const seed = await bip39.mnemonicToSeed(mnemonic) + const entropy = await bip39.mnemonicToEntropy(mnemonic) + + return { + seed, + entropy, + type: KeyType.HDWalletRootKey, + } +} + +export const createAlgo25WalletKeyDataFromMnemonic = async ( + mnemonic: string, +): Promise => { + const seed = seedFromMnemonic(mnemonic) + const keyPair = nacl.sign.keyPair.fromSeed(seed) + + return { + seed: Buffer.from(seed), + entropy: Buffer.from(seed).toString('hex'), + publicKey: encodeAlgorandAddress(keyPair.publicKey), + type: KeyType.Algo25Key, + } +} diff --git a/packages/kms/src/models/keys.ts b/packages/kms/src/models/keys.ts index 95850870d..b2e52c808 100644 --- a/packages/kms/src/models/keys.ts +++ b/packages/kms/src/models/keys.ts @@ -14,6 +14,7 @@ export const KeyType = { HDWalletRootKey: 'hdwallet-root-key', HDWalletDerivedKey: 'hdwallet-derived-key', DeterministicP256Key: 'deterministic-p256-key', + Algo25Key: 'algo25-key', } export type KeyType = (typeof KeyType)[keyof typeof KeyType] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 45d3b35ff..c0bcea1eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -562,6 +562,9 @@ importers: packages/accounts: dependencies: + '@algorandfoundation/algokit-utils': + specifier: 'catalog:' + version: 10.0.0-alpha.29 '@algorandfoundation/xhd-wallet-api': specifier: 'catalog:' version: 2.0.0-canary.1 @@ -601,6 +604,9 @@ importers: react: specifier: 'catalog:' version: 19.2.0 + tweetnacl: + specifier: ^1.0.3 + version: 1.0.3 uuid: specifier: 'catalog:' version: 13.0.0