diff --git a/.env.example b/.env.example index fe78808..64314da 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ EXPO_PUBLIC_WDK_INDEXER_BASE_URL=https://wdk-api.tether.io EXPO_PUBLIC_WDK_INDEXER_API_KEY=PUT_WDK_API_KEY_HERE EXPO_PUBLIC_TRON_API_KEY=PUT_HERE -EXPO_PUBLIC_TRON_API_SECRET=PUT_HERE \ No newline at end of file +EXPO_PUBLIC_TRON_API_SECRET=PUT_HERE +EXPO_PUBLIC_SEPOLIA_RPC=https://ethereum-sepolia-rpc.publicnode.com diff --git a/app.json b/app.json index c2f76ee..dac5d57 100644 --- a/app.json +++ b/app.json @@ -31,7 +31,11 @@ "statusBar": { "style": "light", "backgroundColor": "#000000" - } + }, + "permissions": [ + "android.permission.CAMERA", + "android.permission.RECORD_AUDIO" + ] }, "web": { "output": "static", @@ -71,7 +75,8 @@ "compileSdkVersion": 36, "buildToolsVersion": "36.0.0", "enableProguardInReleaseBuilds": true, - "enableShrinkResourcesInReleaseBuilds": true + "enableShrinkResourcesInReleaseBuilds": true, + "enableMinifyInReleaseBuilds": true } } ], @@ -80,6 +85,12 @@ "experiments": { "typedRoutes": true, "reactCompiler": true + }, + "extra": { + "router": {}, + "eas": { + "projectId": "e57e4bd6-28ea-4fbc-8090-c10adf17773d" + } } } } diff --git a/eas.json b/eas.json new file mode 100644 index 0000000..a51dd37 --- /dev/null +++ b/eas.json @@ -0,0 +1,17 @@ +{ + "build": { + "apk": { + "android": { + "buildType": "apk" + } + }, + "development": { + "developmentClient": true, + "distribution": "internal" + }, + "preview": { + "distribution": "internal" + }, + "production": {} + } +} diff --git a/package-lock.json b/package-lock.json index 0acbe56..11020d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@craftzdog/react-native-buffer": "^6.1.0", "@expo/vector-icons": "^15.0.2", - "@gorhom/bottom-sheet": "^5.2.6", + "@gorhom/bottom-sheet": "^5.2.8", "@react-native-async-storage/async-storage": "^2.2.0", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", @@ -22,6 +22,7 @@ "@tetherto/wdk-pricing-provider": "^1.0.0-beta.1", "@tetherto/wdk-react-native-provider": "^1.0.0-beta.3", "@tetherto/wdk-uikit-react-native": "^1.0.0-beta.2", + "@ton/core": "^0.63.0", "b4a": "^1.7.2", "bip39": "^3.1.0", "browserify-zlib": "^0.2.0", @@ -47,6 +48,7 @@ "http2-wrapper": "^2.2.1", "https-browserify": "^1.0.0", "lucide-react-native": "^0.544.0", + "nanoid": "^5.1.6", "nice-grpc-web": "^3.3.8", "path-browserify": "^1.0.1", "process": "^0.11.10", @@ -3661,9 +3663,9 @@ "license": "0BSD" }, "node_modules/@gorhom/bottom-sheet": { - "version": "5.2.6", - "resolved": "https://registry.npmjs.org/@gorhom/bottom-sheet/-/bottom-sheet-5.2.6.tgz", - "integrity": "sha512-vmruJxdiUGDg+ZYcDmS30XDhq/h/+QkINOI5LY/uGjx8cPGwgJW0H6AB902gNTKtccbiKe/rr94EwdmIEz+LAQ==", + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@gorhom/bottom-sheet/-/bottom-sheet-5.2.8.tgz", + "integrity": "sha512-+N27SMpbBxXZQ/IA2nlEV6RGxL/qSFHKfdFKcygvW+HqPG5jVNb1OqehLQsGfBP+Up42i0gW5ppI+DhpB7UCzA==", "license": "MIT", "dependencies": { "@gorhom/portal": "1.0.14", @@ -3699,6 +3701,24 @@ "react-native": "*" } }, + "node_modules/@gorhom/portal/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -5729,6 +5749,24 @@ "react": ">= 18.2.0" } }, + "node_modules/@react-navigation/core/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/@react-navigation/elements": { "version": "2.6.5", "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.6.5.tgz", @@ -5786,6 +5824,24 @@ "react-native-screens": ">= 4.0.0" } }, + "node_modules/@react-navigation/native/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/@react-navigation/routers": { "version": "7.5.1", "resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-7.5.1.tgz", @@ -5795,6 +5851,24 @@ "nanoid": "^3.3.11" } }, + "node_modules/@react-navigation/routers/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/@reduxjs/toolkit": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.0.tgz", @@ -7183,6 +7257,15 @@ "@ton/core": ">=0.59.0" } }, + "node_modules/@ton/core": { + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@ton/core/-/core-0.63.0.tgz", + "integrity": "sha512-uBc0WQNYVzjAwPvIazf0Ryhpv4nJd4dKIuHoj766gUdwe8sVzGM+TxKKKJETL70hh/mxACyUlR4tAwN0LWDNow==", + "license": "MIT", + "peerDependencies": { + "@ton/crypto": ">=3.2.0" + } + }, "node_modules/@ton/crypto": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/@ton/crypto/-/crypto-3.3.0.tgz", @@ -14998,6 +15081,24 @@ } } }, + "node_modules/expo-router/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/expo-router/node_modules/semver": { "version": "7.6.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", @@ -19850,9 +19951,9 @@ "license": "ISC" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", + "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", "funding": [ { "type": "github", @@ -19861,10 +19962,10 @@ ], "license": "MIT", "bin": { - "nanoid": "bin/nanoid.cjs" + "nanoid": "bin/nanoid.js" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": "^18 || >=20" } }, "node_modules/napi-postinstall": { @@ -20966,6 +21067,24 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -21834,6 +21953,24 @@ "react-native-svg": "*" } }, + "node_modules/react-native-qr-svg/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/react-native-quick-base64": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/react-native-quick-base64/-/react-native-quick-base64-2.2.2.tgz", @@ -21870,9 +22007,9 @@ } }, "node_modules/react-native-reanimated": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.3.tgz", - "integrity": "sha512-GP8wsi1u3nqvC1fMab/m8gfFwFyldawElCcUSBJQgfrXeLmsPPUOpDw44lbLeCpcwUuLa05WTVePdTEwCLTUZg==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.6.tgz", + "integrity": "sha512-F+ZJBYiok/6Jzp1re75F/9aLzkgoQCOh4yxrnwATa8392RvM3kx+fiXXFvwcgE59v48lMwd9q0nzF1oJLXpfxQ==", "license": "MIT", "dependencies": { "react-native-is-edge-to-edge": "^1.2.1", diff --git a/package.json b/package.json index bafed0e..a98206b 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "dependencies": { "@craftzdog/react-native-buffer": "^6.1.0", "@expo/vector-icons": "^15.0.2", - "@gorhom/bottom-sheet": "^5.2.6", + "@gorhom/bottom-sheet": "^5.2.8", "@react-native-async-storage/async-storage": "^2.2.0", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", @@ -35,12 +35,13 @@ "@tetherto/wdk-pricing-provider": "^1.0.0-beta.1", "@tetherto/wdk-react-native-provider": "^1.0.0-beta.3", "@tetherto/wdk-uikit-react-native": "^1.0.0-beta.2", + "@ton/core": "^0.63.0", "b4a": "^1.7.2", "bip39": "^3.1.0", "browserify-zlib": "^0.2.0", "decimal.js": "^10.6.0", "events": "^3.3.0", - "expo": "~54.0.8", + "expo": "~54.0.32", "expo-build-properties": "~1.0.9", "expo-camera": "~17.0.8", "expo-clipboard": "~8.0.7", @@ -60,6 +61,7 @@ "http2-wrapper": "^2.2.1", "https-browserify": "^1.0.0", "lucide-react-native": "^0.544.0", + "nanoid": "^5.1.6", "nice-grpc-web": "^3.3.8", "path-browserify": "^1.0.1", "process": "^0.11.10", diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 4df44bd..0cf9421 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -7,10 +7,14 @@ import { StatusBar } from 'expo-status-bar'; import { useEffect } from 'react'; import { View } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { BottomSheetModalProvider } from '@gorhom/bottom-sheet'; import 'react-native-reanimated'; + import getChainsConfig from '@/config/get-chains-config'; import { Toaster } from 'sonner-native'; import { colors } from '@/constants/colors'; +import WalletSwitcherSheet from '../components/WalletSwitcherSheet'; +import { WalletSwitcherProvider } from '../hooks/use-wallet-switcher'; SplashScreen.preventAutoHideAsync(); @@ -28,8 +32,6 @@ export default function RootLayout() { const initApp = async () => { try { await WDKService.initialize(); - } catch (error) { - console.error('Failed to initialize services in app layout:', error); } finally { SplashScreen.hideAsync(); } @@ -40,47 +42,50 @@ export default function RootLayout() { return ( - - - - - - - - - - - + + + + + + + + + + + {/* MUST be mounted once */} + + + + + + + + ); } diff --git a/src/app/authorize.tsx b/src/app/authorize.tsx index 1cde1e7..14cac99 100644 --- a/src/app/authorize.tsx +++ b/src/app/authorize.tsx @@ -4,7 +4,6 @@ import { Fingerprint, Shield } from 'lucide-react-native'; import React, { useEffect, useState } from 'react'; import { ActivityIndicator, Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import parseWorkletError from '@/utils/parse-worklet-error'; import { colors } from '@/constants/colors'; import getErrorMessage from '@/utils/get-error-message'; diff --git a/src/app/scan-qr.tsx b/src/app/scan-qr.tsx index 55cc435..641c322 100644 --- a/src/app/scan-qr.tsx +++ b/src/app/scan-qr.tsx @@ -13,9 +13,16 @@ const qrSize = screenWidth * 0.7; export default function ScanQRScreen() { const insets = useSafeAreaInsets(); const router = useDebouncedNavigation(); - const { returnRoute, ...params } = useLocalSearchParams(); + const { returnRoute, scanType, returnParamKey, ...params } = useLocalSearchParams(); + const scanTypeValue = Array.isArray(scanType) ? scanType[0] : scanType; + const returnParamKeyValue = Array.isArray(returnParamKey) ? returnParamKey[0] : returnParamKey; const [permission, requestPermission] = useCameraPermissions(); const [scanned, setScanned] = useState(false); + const isAddressScan = scanTypeValue !== 'text'; + const titleText = isAddressScan ? 'Scan QR code to make payment.' : 'Scan QR code to import.'; + const subtitleText = isAddressScan + ? 'Hold your phone up to the QR code.' + : 'Hold your phone steady to capture the code.'; const handleBarCodeScanned = useCallback( ({ type, data }: { type: string; data: string }) => { @@ -23,22 +30,39 @@ export default function ScanQRScreen() { setScanned(true); - // Validate if it's a valid address format (basic validation) - if (!data || data.length < 10) { - Alert.alert('Invalid QR Code', 'The scanned QR code does not contain a valid address.', [ - { - text: 'Try Again', - onPress: () => setScanned(false), - }, - ]); - return; + if (isAddressScan) { + // Validate if it's a valid address format (basic validation) + if (!data || data.length < 10) { + Alert.alert('Invalid QR Code', 'The scanned QR code does not contain a valid address.', [ + { + text: 'Try Again', + onPress: () => setScanned(false), + }, + ]); + return; + } + } else { + if (!data || data.trim().length === 0) { + Alert.alert('Invalid QR Code', 'The scanned QR code does not contain valid text.', [ + { + text: 'Try Again', + onPress: () => setScanned(false), + }, + ]); + return; + } } // Navigate back with the scanned address if (returnRoute) { + const paramKey = returnParamKeyValue + ? String(returnParamKeyValue) + : isAddressScan + ? 'scannedAddress' + : 'scannedText'; router.replace({ pathname: returnRoute as any, - params: { scannedAddress: data, ...params }, + params: { [paramKey]: data, ...params }, }); } else { // Fallback - navigate to send flow starting with token selection @@ -48,7 +72,7 @@ export default function ScanQRScreen() { }); } }, - [scanned, router, returnRoute, params] + [scanned, router, returnRoute, params, isAddressScan, returnParamKeyValue] ); const handleClose = useCallback(() => { @@ -117,8 +141,8 @@ export default function ScanQRScreen() { {/* Title Section */} - Scan QR code to make payment. - Hold your phone up to the QR code. + {titleText} + {subtitleText} {/* Camera View */} @@ -135,7 +159,7 @@ export default function ScanQRScreen() { - Scan address + {isAddressScan ? 'Scan address' : 'Scan text'} diff --git a/src/app/settings.tsx b/src/app/settings.tsx index 0888b40..ed66ae1 100644 --- a/src/app/settings.tsx +++ b/src/app/settings.tsx @@ -2,13 +2,22 @@ import Header from '@/components/header'; import { clearAvatar } from '@/config/avatar-options'; import { networkConfigs } from '@/config/networks'; import useWalletAvatar from '@/hooks/use-wallet-avatar'; +import { useWalletSwitcher } from '@/hooks/use-wallet-switcher'; import getDisplaySymbol from '@/utils/get-display-symbol'; import { NetworkType, useWallet } from '@tetherto/wdk-react-native-provider'; import * as Clipboard from 'expo-clipboard'; import { useDebouncedNavigation } from '@/hooks/use-debounced-navigation'; import { Copy, Info, Shield, Trash2, Wallet } from 'lucide-react-native'; import React from 'react'; -import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { + ActivityIndicator, + Alert, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { toast } from 'sonner-native'; import { colors } from '@/constants/colors'; @@ -18,6 +27,9 @@ export default function SettingsScreen() { const router = useDebouncedNavigation(); const { wallet, clearWallet, addresses } = useWallet(); const avatar = useWalletAvatar(); + const { wallets, activeWallet, deleteWallet, resetWallets } = useWalletSwitcher(); + const [deletingWalletId, setDeletingWalletId] = React.useState(null); + const [isResettingWallets, setIsResettingWallets] = React.useState(false); const handleDeleteWallet = () => { Alert.alert( @@ -33,13 +45,16 @@ export default function SettingsScreen() { style: 'destructive', onPress: async () => { try { - await clearWallet(); + setIsResettingWallets(true); + await resetWallets(); await clearAvatar(); - toast.success('Wallet deleted successfully'); + toast.success('All wallet data cleared'); router.dismissAll('/'); } catch (error) { console.error('Failed to delete wallet:', error); toast.error('Failed to delete wallet'); + } finally { + setIsResettingWallets(false); } }, }, @@ -47,6 +62,36 @@ export default function SettingsScreen() { ); }; + const handleDeleteSelectedWallet = (walletId: string, walletName: string) => { + const isActive = walletId === activeWallet?.id; + const hasOtherWallets = wallets.length > 1; + const message = isActive + ? hasOtherWallets + ? 'This will delete the active wallet and switch to another wallet. Make sure you have backed up the recovery phrase.' + : 'This will delete your only wallet on this device. Make sure you have backed up the recovery phrase.' + : 'This will permanently delete this wallet from the device. Make sure you have backed up the recovery phrase.'; + + Alert.alert('Delete Wallet', message, [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: async () => { + try { + setDeletingWalletId(walletId); + await deleteWallet(walletId); + toast.success(`Deleted ${walletName}`); + } catch (error) { + console.error('Failed to delete wallet:', error); + toast.error('Failed to delete wallet'); + } finally { + setDeletingWalletId(null); + } + }, + }, + ]); + }; + const handleCopyAddress = async (address: string, networkName: string) => { await Clipboard.setStringAsync(address); toast.success(`${networkName} address copied to clipboard`); @@ -92,7 +137,8 @@ export default function SettingsScreen() { Enabled Assets - {wallet?.enabledAssets?.map(asset => getDisplaySymbol(asset)).join(', ') || 'None'} + {wallet?.enabledAssets?.map((asset) => getDisplaySymbol(asset)).join(', ') || + 'None'} @@ -127,6 +173,92 @@ export default function SettingsScreen() { + {/* Manage Wallets Section */} + + + + Manage Wallets + + + + {wallets.length === 0 ? ( + + No wallets found + + Create or import a wallet to start managing them here. + + + ) : ( + <> + {wallets.map((item, index) => { + const isActive = item.id === activeWallet?.id; + const showInlineDelete = wallets.length > 1; + return ( + + + {item.name} + + {item.address + ? `${item.address.slice(0, 10)}...${item.address.slice(-10)}` + : 'No address'} + + + + {isActive && Active} + + {showInlineDelete && ( + handleDeleteSelectedWallet(item.id, item.name)} + style={styles.walletDeleteButton} + disabled={isResettingWallets || deletingWalletId === item.id} + accessibilityLabel={`Delete ${item.name}`} + > + {deletingWalletId === item.id ? ( + + ) : ( + + )} + + )} + + ); + })} + + + )} + + + {wallets.length === 1 && ( + + + {isResettingWallets ? ( + + ) : ( + + )} + + {isResettingWallets ? 'Deleting...' : 'Delete Wallet'} + + + + + Deleting your wallet will remove all data from this device. Make sure you have backed + up your recovery phrase before proceeding. + + + )} + + {/* About Section */} @@ -147,23 +279,7 @@ export default function SettingsScreen() { - {/* Danger Zone */} - - - - Danger Zone - - - - - Delete Wallet - - - - Deleting your wallet will remove all data from this device. Make sure you have backed up - your recovery phrase before proceeding. - - + {/* Danger Zone removed (now under Manage Wallets) */} ); @@ -256,13 +372,69 @@ const styles = StyleSheet.create({ color: colors.text, fontFamily: 'monospace', }, - dangerSection: { - paddingHorizontal: 20, - paddingTop: 32, - paddingBottom: 40, + walletsCard: { + backgroundColor: colors.card, + borderRadius: 12, + padding: 16, + }, + walletRow: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: colors.borderDark, + }, + walletRowLast: { + borderBottomWidth: 0, + }, + walletInfo: { + flex: 1, + marginRight: 12, + }, + walletName: { + fontSize: 14, + color: colors.text, + fontWeight: '600', + marginBottom: 4, + }, + walletAddress: { + fontSize: 12, + color: colors.textSecondary, + fontFamily: 'monospace', + }, + walletActiveBadge: { + fontSize: 11, + fontWeight: '600', + color: colors.black, + backgroundColor: colors.primary, + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 999, + marginRight: 8, + }, + walletDeleteButton: { + padding: 6, + borderRadius: 8, + borderWidth: 1, + borderColor: colors.border, + }, + walletEmptyState: { + paddingVertical: 20, + alignItems: 'center', + }, + walletEmptyTitle: { + fontSize: 14, + fontWeight: '600', + color: colors.text, + marginBottom: 6, + }, + walletEmptyText: { + fontSize: 12, + color: colors.textSecondary, + textAlign: 'center', }, - dangerTitle: { - color: colors.danger, + dangerPanel: { + marginTop: 12, }, deleteButton: { flexDirection: 'row', diff --git a/src/app/wallet-setup/complete.tsx b/src/app/wallet-setup/complete.tsx index 3444d65..95b341e 100644 --- a/src/app/wallet-setup/complete.tsx +++ b/src/app/wallet-setup/complete.tsx @@ -1,17 +1,36 @@ import { CommonActions, useNavigation } from '@react-navigation/native'; import { useWallet } from '@tetherto/wdk-react-native-provider'; +import { ensureDeviceAuthentication } from '@/utils/ensure-device-auth'; import { useLocalSearchParams } from 'expo-router'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { colors } from '@/constants/colors'; +import getErrorMessage from '@/utils/get-error-message'; +import { clearWalletCache } from '@/utils/wallet-cache'; +import { + addWallet, + createWalletEntry, + setActiveWalletId, + updateWallet, +} from '@/utils/wallet-storage'; +import { saveWalletMnemonic } from '@/utils/wallet-secrets'; export default function CompleteScreen() { const navigation = useNavigation(); const insets = useSafeAreaInsets(); - const params = useLocalSearchParams<{ walletName: string; mnemonic: string }>(); - const { createWallet, isLoading } = useWallet(); + const params = useLocalSearchParams<{ + walletName: string; + mnemonic: string; + avatarId?: string; + }>(); + const { createWallet, isLoading, addresses } = useWallet(); const [walletCreated, setWalletCreated] = useState(false); + const addressesRef = useRef(addresses); + + useEffect(() => { + addressesRef.current = addresses; + }, [addresses]); useEffect(() => { // Auto-create wallet when screen loads @@ -19,27 +38,112 @@ export default function CompleteScreen() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const getPrimaryAddress = (value?: typeof addresses) => { + if (!value) return undefined; + const candidate = Object.values(value).find(Boolean); + return typeof candidate === 'string' ? candidate : undefined; + }; + + const getAddressMap = (value?: typeof addresses) => { + if (!value) return undefined; + const entries = Object.entries(value).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' && !!entry[1] + ); + return entries.length ? Object.fromEntries(entries) : undefined; + }; + + const areAddressMapsEqual = ( + left?: Record, + right?: Record + ) => { + if (!left && !right) return true; + if (!left || !right) return false; + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + if (leftKeys.length !== rightKeys.length) return false; + return leftKeys.every((key) => left[key] === right[key]); + }; + + const waitForPrimaryAddress = async (previous?: string) => { + for (let attempt = 0; attempt < 20; attempt += 1) { + const next = getPrimaryAddress(addressesRef.current); + if (next && next !== previous) { + return next; + } + await new Promise((resolve) => setTimeout(resolve, 300)); + } + return getPrimaryAddress(addressesRef.current); + }; + const createWalletWithWDK = async () => { if (walletCreated) return; try { - const walletName = params.walletName || 'My Wallet'; - const mnemonic = params.mnemonic.split(',').join(' '); + const walletNameValue = Array.isArray(params.walletName) + ? params.walletName[0] + : params.walletName; + const mnemonicValue = Array.isArray(params.mnemonic) ? params.mnemonic[0] : params.mnemonic; + const walletName = walletNameValue || 'My Wallet'; + const mnemonic = mnemonicValue?.split(',').join(' '); + if (!mnemonic) { + throw new Error('Missing seed phrase for wallet creation'); + } + + await ensureDeviceAuthentication(); + + const previousAddress = getPrimaryAddress(addressesRef.current); + const previousAddressMap = getAddressMap(addressesRef.current); + await clearWalletCache(); // Use the wallet context to create the wallet await createWallet({ name: walletName, mnemonic, }); + const avatarIdValue = Array.isArray(params.avatarId) ? params.avatarId[0] : params.avatarId; + const avatarId = avatarIdValue ? Number(avatarIdValue) : undefined; + const storedWallet = createWalletEntry(walletName, undefined, avatarId); + await addWallet(storedWallet); + await saveWalletMnemonic(storedWallet.id, mnemonic); + await setActiveWalletId(storedWallet.id); + if (__DEV__) { + console.info('[wallet-create] stored wallet', { + id: storedWallet.id, + name: storedWallet.name, + }); + } + + const primaryAddress = await waitForPrimaryAddress(previousAddress); + const addressMap = getAddressMap(addressesRef.current); + // Persist addresses when they change, or when a new wallet has none yet. + const addressesChanged = + (primaryAddress && primaryAddress !== previousAddress) || + !areAddressMapsEqual(previousAddressMap, addressMap); + const shouldPersistAddresses = + (!!primaryAddress || !!addressMap) && !storedWallet.address && !storedWallet.addresses + ? true + : addressesChanged; + if (shouldPersistAddresses && primaryAddress) { + await updateWallet(storedWallet.id, { + address: primaryAddress, + addresses: addressMap, + }); + } else if (shouldPersistAddresses && addressMap) { + await updateWallet(storedWallet.id, { addresses: addressMap }); + } else if (__DEV__) { + console.info('[wallet-create] address not updated (no change detected)'); + } setWalletCreated(true); } catch (error) { console.error('Failed to create wallet:', error); - Alert.alert( - 'Wallet Creation Failed', - 'There was an issue creating your wallet. Please try again.', - [{ text: 'Retry', onPress: () => createWalletWithWDK() }] + const message = getErrorMessage( + error, + 'There was an issue creating your wallet. Please try again.' ); + Alert.alert('Wallet Creation Failed', message, [ + { text: 'Retry', onPress: () => createWalletWithWDK() }, + ]); } }; diff --git a/src/app/wallet-setup/confirm-phrase.tsx b/src/app/wallet-setup/confirm-phrase.tsx index 0fcb47c..c2e4e58 100644 --- a/src/app/wallet-setup/confirm-phrase.tsx +++ b/src/app/wallet-setup/confirm-phrase.tsx @@ -18,7 +18,7 @@ export default function ConfirmPhraseScreen() { const params = useLocalSearchParams<{ mnemonic?: string; walletName?: string; - avatar?: string; + avatarId?: string; }>(); const [selectedWords, setSelectedWords] = useState<{ [key: number]: string }>({}); const [wordPositions, setWordPositions] = useState([]); @@ -104,7 +104,7 @@ export default function ConfirmPhraseScreen() { pathname: './complete', params: { walletName: params.walletName, - avatar: params.avatar, + avatarId: params.avatarId, mnemonic: params.mnemonic, }, }); diff --git a/src/app/wallet-setup/import-name-wallet.tsx b/src/app/wallet-setup/import-name-wallet.tsx index 167e5fa..23ffc58 100644 --- a/src/app/wallet-setup/import-name-wallet.tsx +++ b/src/app/wallet-setup/import-name-wallet.tsx @@ -1,10 +1,13 @@ -import avatarOptions, { setAvatar } from '@/config/avatar-options'; +import avatarOptions from '@/config/avatar-options'; +import { addWallet, createWalletEntry, setActiveWalletId, updateWallet } from '@/utils/wallet-storage'; +import { saveWalletMnemonic } from '@/utils/wallet-secrets'; import { CommonActions, useNavigation } from '@react-navigation/native'; import { useWallet } from '@tetherto/wdk-react-native-provider'; import { useLocalSearchParams } from 'expo-router'; import { useDebouncedNavigation } from '@/hooks/use-debounced-navigation'; +import { clearWalletCache } from '@/utils/wallet-cache'; import { ChevronLeft } from 'lucide-react-native'; -import React, { useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { colors } from '@/constants/colors'; import { ActivityIndicator, @@ -26,14 +29,56 @@ export default function ImportNameWalletScreen() { const navigation = useNavigation(); const params = useLocalSearchParams(); const insets = useSafeAreaInsets(); - const { createWallet } = useWallet(); + const { createWallet, addresses } = useWallet(); const [walletName, setWalletName] = useState(''); const [selectedAvatar, setSelectedAvatar] = useState(avatarOptions[0]); const [isImporting, setIsImporting] = useState(false); + const addressesRef = useRef(addresses); + + useEffect(() => { + addressesRef.current = addresses; + }, [addresses]); // Get the seed phrase from navigation params const seedPhrase = params.seedPhrase ? decodeURIComponent(params.seedPhrase as string) : ''; + const getPrimaryAddress = (value?: typeof addresses) => { + if (!value) return undefined; + const candidate = Object.values(value).find(Boolean); + return typeof candidate === 'string' ? candidate : undefined; + }; + + const getAddressMap = (value?: typeof addresses) => { + if (!value) return undefined; + const entries = Object.entries(value).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' && !!entry[1] + ); + return entries.length ? Object.fromEntries(entries) : undefined; + }; + + const areAddressMapsEqual = ( + left?: Record, + right?: Record + ) => { + if (!left && !right) return true; + if (!left || !right) return false; + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + if (leftKeys.length !== rightKeys.length) return false; + return leftKeys.every((key) => left[key] === right[key]); + }; + + const waitForPrimaryAddress = async (previous?: string) => { + for (let attempt = 0; attempt < 20; attempt += 1) { + const next = getPrimaryAddress(addressesRef.current); + if (next && next !== previous) { + return next; + } + await new Promise((resolve) => setTimeout(resolve, 300)); + } + return getPrimaryAddress(addressesRef.current); + }; + const handleNext = async () => { if (!seedPhrase) { Alert.alert('Error', 'No seed phrase provided. Please go back and enter your seed phrase.'); @@ -43,10 +88,41 @@ export default function ImportNameWalletScreen() { setIsImporting(true); try { + const previousAddress = getPrimaryAddress(addressesRef.current); + const previousAddressMap = getAddressMap(addressesRef.current); + await clearWalletCache(); // Use the context's createWallet method which handles everything including unlocking await createWallet({ name: walletName, mnemonic: seedPhrase }); - await setAvatar(selectedAvatar.id); - + const storedWallet = createWalletEntry(walletName, undefined, selectedAvatar.id); + await addWallet(storedWallet); + await saveWalletMnemonic(storedWallet.id, seedPhrase); + await setActiveWalletId(storedWallet.id); + if (__DEV__) { + console.info('[wallet-import] stored wallet', { + id: storedWallet.id, + name: storedWallet.name, + }); + } + const primaryAddress = await waitForPrimaryAddress(previousAddress); + const addressMap = getAddressMap(addressesRef.current); + // Persist addresses when they change, or when a new wallet has none yet. + const addressesChanged = + (primaryAddress && primaryAddress !== previousAddress) || + !areAddressMapsEqual(previousAddressMap, addressMap); + const shouldPersistAddresses = + (!!primaryAddress || !!addressMap) && !storedWallet.address && !storedWallet.addresses + ? true + : addressesChanged; + if (shouldPersistAddresses && primaryAddress) { + await updateWallet(storedWallet.id, { + address: primaryAddress, + addresses: addressMap, + }); + } else if (shouldPersistAddresses && addressMap) { + await updateWallet(storedWallet.id, { addresses: addressMap }); + } else if (__DEV__) { + console.info('[wallet-import] address not updated (no change detected)'); + } toast.success('Your wallet has been imported successfully.'); navigation.dispatch( @@ -82,14 +158,14 @@ export default function ImportNameWalletScreen() { style={styles.content} behavior={Platform.OS === 'ios' ? 'padding' : 'height'} > - + Name Your Wallet This name is just for you and can be changed later. Wallet Name* - 💼 + {selectedAvatar.emoji} (Array(12).fill('')); + const params = useLocalSearchParams<{ scannedText?: string }>(); + const lastScannedRef = useRef(null); const handleWordChange = (index: number, text: string) => { const newWords = [...secretWords]; @@ -28,33 +32,59 @@ export default function ImportWalletScreen() { setSecretWords(newWords); }; - const handlePaste = async () => { - try { - const clipboardContent = await Clipboard.getStringAsync(); - - if (!clipboardContent.trim()) { - toast.error('Empty Clipboard! No text found in clipboard'); - return; - } + const normalizePhrase = useCallback((phrase: string) => { + return phrase + .trim() + .replace(/[\n\r]+/g, ' ') + .replace(/,/g, ' ') + .split(/\s+/) + .filter(Boolean); + }, []); - const words = clipboardContent.trim().split(/\s+/).slice(0, 12); + const applyPhraseToFields = useCallback( + (phrase: string) => { + const words = normalizePhrase(phrase); - if (words.length < 12) { + if (words.length !== 12) { toast.error( - `Invalid Phrase! Found only ${words.length} words in clipboard. Please ensure you have exactly 12 words.` + `Invalid Phrase! Found ${words.length} words. Please ensure you have exactly 12 words.` ); - return; + return false; } - const newWords = [...secretWords]; + const newWords = Array(12).fill(''); words.forEach((word, index) => { - if (index < 12) { - newWords[index] = word.toLowerCase().trim(); - } + newWords[index] = word.toLowerCase().trim(); }); setSecretWords(newWords); + return true; + }, + [normalizePhrase] + ); + + useEffect(() => { + if (!params.scannedText || params.scannedText === lastScannedRef.current) { + return; + } + + lastScannedRef.current = params.scannedText; + if (applyPhraseToFields(params.scannedText)) { + toast.success('Phrase imported from QR code'); + } + }, [applyPhraseToFields, params.scannedText]); - toast.success('12 words have been pasted from clipboard'); + const handlePaste = async () => { + try { + const clipboardContent = await Clipboard.getStringAsync(); + + if (!clipboardContent.trim()) { + toast.error('Empty Clipboard! No text found in clipboard'); + return; + } + + if (applyPhraseToFields(clipboardContent)) { + toast.success('12 words have been pasted from clipboard'); + } } catch (error) { console.error('Paste error:', error); toast.error('Could not paste from clipboard'); @@ -62,32 +92,41 @@ export default function ImportWalletScreen() { }; const handleScanText = () => { - Alert.alert('Scan Text', 'Camera functionality would open here to scan QR code or text', [ - { text: 'OK' }, - ]); + router.push({ + pathname: '/scan-qr', + params: { + returnRoute: '/wallet-setup/import-wallet', + scanType: 'text', + returnParamKey: 'scannedText', + }, + }); }; const isFormValid = () => { - return secretWords.every(word => word.trim().length > 0); + return secretWords.every((word) => word.trim().length > 0); }; const validateSeedPhrase = (phrase: string): boolean => { - const words = phrase - .trim() - .split(' ') - .filter(word => word.length > 0); + const normalized = normalizePhrase(phrase).join(' '); + if (!normalized) return false; + + const words = normalized.split(' '); + if (words.length !== 12) return false; - // Check if we have exactly 12 or 24 words - if (words.length !== 12 && words.length !== 24) { - return false; + const hasBip39 = typeof (bip39 as any)?.validateMnemonic === 'function'; + if (__DEV__) { + console.info('[import-wallet] validate mnemonic', { + hasBip39, + wordCount: words.length, + }); } - // Basic word validation - each word should be at least 3 characters - const validWords = words.every( - word => word.length >= 3 && /^[a-z]+$/.test(word) // only lowercase letters - ); + if (hasBip39) { + return (bip39 as any).validateMnemonic(normalized); + } - return validWords; + // Fallback: basic validation if bip39 isn't available + return words.every((word) => word.length >= 3 && /^[a-z]+$/.test(word)); }; const handleImportWallet = () => { @@ -105,7 +144,7 @@ export default function ImportWalletScreen() { if (!validateSeedPhrase(seedPhrase)) { Alert.alert( 'Invalid Seed Phrase', - 'Please check your seed phrase. Make sure all words are spelled correctly and contain only lowercase letters.', + 'Please check your seed phrase. Make sure all 12 words are spelled correctly and contain only lowercase letters.', [{ text: 'OK' }] ); return; diff --git a/src/app/wallet-setup/name-wallet.tsx b/src/app/wallet-setup/name-wallet.tsx index 921e6ef..2e9bde4 100644 --- a/src/app/wallet-setup/name-wallet.tsx +++ b/src/app/wallet-setup/name-wallet.tsx @@ -22,10 +22,12 @@ export default function NameWalletScreen() { const [selectedAvatar, setSelectedAvatar] = useState(avatarOptions[0]); const handleNext = () => { + // Force a fresh mnemonic when starting a new wallet flow. + const sessionId = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; // Pass wallet name to next screen router.push({ pathname: './secure-wallet', - params: { walletName, avatar: selectedAvatar.emoji }, + params: { walletName, avatarId: String(selectedAvatar.id), sessionId }, }); }; @@ -66,7 +68,7 @@ export default function NameWalletScreen() { Choose an avatar - {avatarOptions.map(avatar => ( + {avatarOptions.map((avatar) => ( (); const [mnemonic, setMnemonic] = useState([]); const [showPhrase, setShowPhrase] = useState(true); @@ -26,15 +27,21 @@ export default function SecureWalletScreen() { const [error, setError] = useState(null); useEffect(() => { - // Generate mnemonic using WDK on mount + // Generate mnemonic using WDK when a new flow starts generateMnemonic(); - }, []); + }, [params.sessionId]); const generateMnemonic = async () => { try { setIsGenerating(true); setError(null); - const prf = await getUniqueId(); + + await ensureDeviceAuthentication(); + + const randomBytes = await Crypto.getRandomBytesAsync(32); + const prf = Array.from(randomBytes) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); const mnemonicString = await WDKService.createSeed({ prf }); if (!mnemonicString) { @@ -73,7 +80,7 @@ export default function SecureWalletScreen() { params: { mnemonic: mnemonic.join(','), walletName: params.walletName, - avatar: params.avatar, + avatarId: params.avatarId, }, }); }; diff --git a/src/app/wallet.tsx b/src/app/wallet.tsx index 1a8cf06..d587acc 100644 --- a/src/app/wallet.tsx +++ b/src/app/wallet.tsx @@ -5,6 +5,7 @@ import { useDebouncedNavigation } from '@/hooks/use-debounced-navigation'; import { ArrowDownLeft, ArrowUpRight, + ChevronDown, Palette, QrCode, Settings, @@ -32,6 +33,7 @@ import formatTokenAmount from '@/utils/format-token-amount'; import formatUSDValue from '@/utils/format-usd-value'; import useWalletAvatar from '@/hooks/use-wallet-avatar'; import { colors } from '@/constants/colors'; +import { useWalletSwitcher } from '@/hooks/use-wallet-switcher'; type AggregatedBalance = ({ denomination: string; @@ -66,14 +68,38 @@ export default function WalletScreen() { addresses, transactions: walletTransactions, } = useWallet(); + const { wallets, activeWallet, isSwitchingWallet, open: openWalletSwitcher } = useWalletSwitcher(); const [refreshing, setRefreshing] = useState(false); const [aggregatedBalances, setAggregatedBalances] = useState([]); const [transactions, setTransactions] = useState([]); const [mounted, setMounted] = useState(false); const avatar = useWalletAvatar(); const scrollY = useRef(new Animated.Value(0)).current; + const primaryAddress = useMemo(() => { + if (!addresses) return undefined; + const value = Object.values(addresses).find(Boolean); + return typeof value === 'string' ? value : undefined; + }, [addresses]); + const fallbackAddress = + activeWallet?.address || + activeWallet?.addresses?.bitcoin || + activeWallet?.addresses?.ethereum || + activeWallet?.addresses?.polygon || + activeWallet?.addresses?.arbitrum || + activeWallet?.addresses?.ton || + undefined; + const fullAddress = + (!isSwitchingWallet && primaryAddress ? primaryAddress : fallbackAddress) || 'No address'; + const displayWalletName = + (!isSwitchingWallet && wallet?.name ? wallet.name : activeWallet?.name) || 'No Wallet'; + const walletId = activeWallet?.id || wallet?.id; + const formattedWalletId = + walletId && walletId.length > 12 + ? `${walletId.slice(0, 6)}...${walletId.slice(-4)}` + : walletId; const hasWallet = !!wallet; + const isWalletBusy = isLoading || isSwitchingWallet; // Redirect to authorization if wallet is not unlocked useEffect(() => { @@ -230,6 +256,11 @@ export default function WalletScreen() { router.push('/settings'); }; + const handleWalletPress = () => { + // Always open wallet switcher to show current wallet and option to add more + openWalletSwitcher(); + }; + const handleRefresh = async () => { if (!wallet) return; @@ -244,12 +275,28 @@ export default function WalletScreen() { }; useEffect(() => { - getAggregatedBalances().then(setAggregatedBalances); + getAggregatedBalances().then((nextBalances) => { + setAggregatedBalances(nextBalances); + if (__DEV__) { + const totalUsd = nextBalances.reduce((sum, asset) => sum + (asset?.usdValue || 0), 0); + console.info('[wallet] balances refreshed', { + count: nextBalances.length, + totalUsd, + }); + } + }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [balances]); useEffect(() => { - getTransactions().then(setTransactions); + getTransactions().then((nextTransactions) => { + setTransactions(nextTransactions); + if (__DEV__) { + console.info('[wallet] transactions refreshed', { + count: nextTransactions.length, + }); + } + }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [walletTransactions?.list, addresses]); @@ -275,12 +322,37 @@ export default function WalletScreen() { }, ]} > - - - {avatar} + + + + {avatar} + + + + + {displayWalletName} + + + {isSwitchingWallet ? ( + Switching… + ) : ( + <> + {fullAddress} + {formattedWalletId ? ( + ID: {formattedWalletId} + ) : null} + + )} + + + + - {wallet?.name || 'No Wallet'} - + @@ -337,10 +409,10 @@ export default function WalletScreen() { - {balances.isLoading ? ( + {balances.isLoading || isSwitchingWallet ? ( @@ -351,7 +423,7 @@ export default function WalletScreen() { {/* Portfolio */} {aggregatedBalances.length > 0 ? ( - aggregatedBalances.map(asset => { + aggregatedBalances.map((asset) => { if (!asset) return null; return ( @@ -405,7 +477,7 @@ export default function WalletScreen() { - {suggestions.map(suggestion => ( + {suggestions.map((suggestion) => ( { Linking.openURL(suggestion.url); @@ -426,7 +498,7 @@ export default function WalletScreen() { style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }} > Activity - {walletTransactions.isLoading ? ( + {walletTransactions.isLoading || isSwitchingWallet ? ( @@ -434,7 +506,7 @@ export default function WalletScreen() { {transactions.length > 0 ? ( - transactions.map(tx => ( + transactions.map((tx) => ( @@ -499,7 +571,7 @@ const styles = StyleSheet.create({ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', - paddingHorizontal: 20, + paddingHorizontal: 16, paddingTop: 20, paddingBottom: 16, borderBottomWidth: 1, @@ -510,30 +582,94 @@ const styles = StyleSheet.create({ flex: 1, marginRight: 12, }, + walletButton: { + backgroundColor: colors.background, + borderRadius: 18, + paddingVertical: 14, + paddingHorizontal: 14, + borderWidth: 1, + borderColor: colors.borderDark, + flex: 1, + marginRight: 8, + position: 'relative', + shadowColor: colors.black, + shadowOpacity: 0.12, + shadowRadius: 10, + shadowOffset: { width: 0, height: 6 }, + elevation: 3, + minHeight: 56, + }, walletIcon: { - width: 24, - height: 24, - borderRadius: 12, + width: 32, + height: 32, + borderRadius: 16, backgroundColor: colors.primary, alignItems: 'center', justifyContent: 'center', - marginRight: 8, + marginRight: 10, }, walletIconText: { - fontSize: 12, + fontSize: 14, }, walletName: { color: colors.text, - fontSize: 16, + fontSize: 15, fontWeight: '600', flex: 1, + flexShrink: 1, + lineHeight: 20, + }, + walletText: { + flex: 1, + marginRight: 6, + }, + walletNameRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + walletAddress: { + color: colors.textSecondary, + fontSize: 11, + fontFamily: 'monospace', + flexWrap: 'wrap', + flexShrink: 1, + marginTop: 3, + }, + walletStatusText: { + color: colors.textSecondary, + fontSize: 11, + marginTop: 3, + }, + walletIdText: { + color: colors.text, + fontSize: 10, + marginTop: 2, + }, + chevronBadge: { + width: 26, + height: 26, + borderRadius: 13, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.tintedBackground, + borderWidth: 1, + borderColor: colors.border, }, headerActions: { flexDirection: 'row', alignItems: 'center', }, settingsButton: { - padding: 8, + padding: 10, + borderRadius: 18, + backgroundColor: colors.background, + borderWidth: 1, + borderColor: colors.borderDark, + minWidth: 56, + minHeight: 56, + alignItems: 'center', + justifyContent: 'center', }, portfolioSection: { paddingHorizontal: 20, diff --git a/src/app/wallets.tsx b/src/app/wallets.tsx new file mode 100644 index 0000000..544c748 --- /dev/null +++ b/src/app/wallets.tsx @@ -0,0 +1,233 @@ +import Header from '@/components/header'; +import { useWalletSwitcher } from '@/hooks/use-wallet-switcher'; +import { useDebouncedNavigation } from '@/hooks/use-debounced-navigation'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { FlatList, View, Text, StyleSheet, TouchableOpacity } from 'react-native'; +import { Wallet, Plus } from 'lucide-react-native'; +import { colors } from '@/constants/colors'; + +export default function WalletsScreen() { + const insets = useSafeAreaInsets(); + const router = useDebouncedNavigation(); + const { wallets, activeWallet, switchWallet } = useWalletSwitcher(); + + const getAddressPreview = (wallet: { address?: string; addresses?: Record }) => { + const entries = wallet.addresses + ? Object.entries(wallet.addresses).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' && !!entry[1] + ) + : []; + if (entries.length > 0) { + const priority = ['bitcoin', 'ethereum', 'polygon', 'arbitrum', 'ton']; + const sorted = entries.sort(([left], [right]) => { + const leftIndex = priority.indexOf(left); + const rightIndex = priority.indexOf(right); + if (leftIndex === -1 && rightIndex === -1) return left.localeCompare(right); + if (leftIndex === -1) return 1; + if (rightIndex === -1) return -1; + return leftIndex - rightIndex; + }); + const [label, value] = sorted[0]; + return `${label.toUpperCase()}: ${value}`; + } + return wallet.address ? wallet.address : 'No address'; + }; + + const handleSelect = async (walletId: string) => { + await switchWallet(walletId); + router.back(); + }; + + const handleCreateWallet = () => { + router.push('/wallet-setup/name-wallet'); + }; + + const handleImportWallet = () => { + router.push('/wallet-setup/import-wallet'); + }; + + return ( + +
+ + + + + All Wallets + + + + { + const isActive = item.id === activeWallet?.id; + return ( + handleSelect(item.id)} + activeOpacity={0.7} + > + + {item.name} + {getAddressPreview(item)} + + + {isActive && Active} + + ); + }} + keyExtractor={(item) => item.id} + showsVerticalScrollIndicator={false} + contentContainerStyle={styles.listContent} + ListEmptyComponent={ + + No wallets found + + Create or import a wallet to get started + + + } + /> + + + + + + Import Wallet + + + + Create Wallet + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + + /* Section */ + section: { + paddingHorizontal: 20, + paddingTop: 24, + }, + sectionHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 16, + }, + sectionTitle: { + fontSize: 16, + fontWeight: '600', + color: colors.text, + marginLeft: 8, + }, + + /* Card */ + card: { + backgroundColor: colors.card, + borderRadius: 12, + paddingHorizontal: 16, + flex: 1, + }, + listContent: { + paddingBottom: 12, + }, + + /* Rows */ + row: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 14, + borderBottomWidth: 1, + borderBottomColor: colors.borderDark, + }, + rowLast: { + borderBottomWidth: 0, + }, + rowContent: { + flex: 1, + marginRight: 12, + }, + + /* Text */ + name: { + fontSize: 14, + fontWeight: '500', + color: colors.text, + marginBottom: 4, + }, + address: { + fontSize: 13, + color: colors.textSecondary, + fontFamily: 'monospace', + flexWrap: 'wrap', + }, + active: { + fontSize: 12, + fontWeight: '600', + color: colors.primary, + }, + emptyState: { + paddingVertical: 40, + alignItems: 'center', + }, + emptyStateText: { + fontSize: 16, + color: colors.text, + fontWeight: '500', + marginBottom: 8, + }, + emptyStateSubtext: { + fontSize: 14, + color: colors.textSecondary, + textAlign: 'center', + }, + footer: { + paddingHorizontal: 20, + paddingTop: 16, + gap: 12, + }, + primaryActionButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.primary, + borderRadius: 12, + paddingVertical: 14, + gap: 8, + }, + primaryActionText: { + fontSize: 16, + fontWeight: '600', + color: colors.black, + }, + secondaryActionButton: { + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.card, + borderRadius: 12, + paddingVertical: 14, + borderWidth: 1, + borderColor: colors.border, + }, + secondaryActionText: { + fontSize: 16, + fontWeight: '600', + color: colors.text, + }, +}); diff --git a/src/components/WalletSwitcherSheet.tsx b/src/components/WalletSwitcherSheet.tsx new file mode 100644 index 0000000..f76630b --- /dev/null +++ b/src/components/WalletSwitcherSheet.tsx @@ -0,0 +1,436 @@ +import { useWalletSwitcher } from '@/hooks/use-wallet-switcher'; +import { useDebouncedNavigation } from '@/hooks/use-debounced-navigation'; +import { Wallet, Plus, Check, ChevronRight } from 'lucide-react-native'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { FlatList, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { BottomSheetBackdrop, BottomSheetModal, BottomSheetView } from '@gorhom/bottom-sheet'; +import avatarOptions from '@/config/avatar-options'; +import { colors } from '@/constants/colors'; +import { toast } from 'sonner-native'; +import getErrorMessage from '@/utils/get-error-message'; + +export default function WalletSwitcherSheet() { + const insets = useSafeAreaInsets(); + const router = useDebouncedNavigation(); + const { isOpen, wallets, activeWallet, isSwitchingWallet, switchWallet, close } = + useWalletSwitcher(); + const bottomSheetModalRef = React.useRef(null); + const [optimisticActiveId, setOptimisticActiveId] = useState( + activeWallet?.id + ); + // Update sheet when isOpen changes + React.useEffect(() => { + if (isOpen) { + bottomSheetModalRef.current?.present(); + } else { + bottomSheetModalRef.current?.dismiss(); + } + }, [isOpen]); + + useEffect(() => { + if (isOpen) { + setOptimisticActiveId(activeWallet?.id); + } + }, [activeWallet?.id, isOpen]); + + const snapPoints = useMemo(() => ['70%', '92%'], []); + + const handleSheetChanges = useCallback( + (index: number) => { + if (index === -1) { + close(); + } + }, + [close] + ); + + const handleSelectWallet = useCallback( + async (walletId: string) => { + try { + if (isSwitchingWallet) return; + setOptimisticActiveId(walletId); + bottomSheetModalRef.current?.dismiss(); + close(); + await switchWallet(walletId); + } catch (error) { + console.error('[wallet-switcher] failed to switch wallet', error); + toast.error(getErrorMessage(error, 'Unable to switch wallet. Please try again.')); + } + }, + [close, isSwitchingWallet, switchWallet] + ); + + const handleCreateWallet = useCallback(() => { + close(); + router.push('/wallet-setup/name-wallet'); + }, [close, router]); + + const handleImportWallet = useCallback(() => { + close(); + router.push('/wallet-setup/import-wallet'); + }, [close, router]); + + const walletList = useMemo(() => wallets ?? [], [wallets]); + + const getAvatarOption = useCallback((avatarId?: number) => { + return avatarOptions.find((option) => option.id === avatarId) ?? avatarOptions[0]; + }, []); + + const getAddressPreview = useCallback( + (wallet: { address?: string; addresses?: Record }) => { + const entries = wallet.addresses + ? Object.entries(wallet.addresses).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' && !!entry[1] + ) + : []; + if (entries.length > 0) { + const priority = ['bitcoin', 'ethereum', 'polygon', 'arbitrum', 'ton']; + const sorted = entries.sort(([left], [right]) => { + const leftIndex = priority.indexOf(left); + const rightIndex = priority.indexOf(right); + if (leftIndex === -1 && rightIndex === -1) return left.localeCompare(right); + if (leftIndex === -1) return 1; + if (rightIndex === -1) return -1; + return leftIndex - rightIndex; + }); + const [label, value] = sorted[0]; + return `${label.toUpperCase()}: ${value}`; + } + return wallet.address ? wallet.address : 'No address'; + }, + [] + ); + + const renderWallet = useCallback( + ({ + item, + index, + }: { + item: { id: string; name: string; address?: string; avatarId?: number }; + index: number; + }) => { + const isActive = item.id === (optimisticActiveId ?? activeWallet?.id); + const isSwitchDisabled = walletList.length <= 1; + const avatarOption = getAvatarOption(item.avatarId); + const addressPreview = getAddressPreview(item); + const [addressLabel, addressValue] = addressPreview.includes(': ') + ? addressPreview.split(': ') + : [undefined, addressPreview]; + + return ( + { + if (!isSwitchDisabled && !isActive) { + handleSelectWallet(item.id); + } + }} + activeOpacity={isSwitchDisabled || isActive ? 1 : 0.7} + > + + + + {avatarOption.emoji} + + + + + {item.name} + + {isActive ? ( + + + Active + + ) : null} + + {addressLabel ? ( + {addressLabel} + ) : null} + {addressValue} + + + + + + {isActive ? null : } + + + ); + }, + [ + activeWallet?.id, + getAddressPreview, + getAvatarOption, + handleSelectWallet, + optimisticActiveId, + walletList.length, + ] + ); + + return ( + ( + + )} + > + + + + + + Select Wallet + Tap a wallet to switch + + + + + + item.id} + showsVerticalScrollIndicator={false} + contentContainerStyle={[styles.listContent, { paddingBottom: insets.bottom + 24 }]} + ListEmptyComponent={ + + No wallets found + + Tap “Import Wallet” or “Create Wallet” to get started + + + } + ListFooterComponent={ + + + Import Wallet + + + + Create Wallet + + + } + /> + + + + ); +} + +const styles = StyleSheet.create({ + sheetBackground: { + backgroundColor: colors.card, + }, + handleIndicator: { + backgroundColor: colors.border, + }, + contentContainer: { + flex: 1, + paddingHorizontal: 20, + paddingTop: 16, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 24, + }, + headerLeft: { + flexDirection: 'row', + alignItems: 'center', + }, + headerText: { + marginLeft: 10, + }, + title: { + fontSize: 18, + fontWeight: '600', + color: colors.text, + }, + subtitle: { + fontSize: 12, + color: colors.textSecondary, + marginTop: 2, + }, + walletsList: { + flex: 1, + }, + listContent: { + paddingBottom: 12, + }, + walletRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 14, + paddingHorizontal: 14, + borderRadius: 14, + backgroundColor: colors.card, + borderWidth: 1, + borderColor: colors.border, + marginBottom: 12, + minHeight: 76, + shadowColor: colors.black, + shadowOpacity: 0.08, + shadowRadius: 6, + shadowOffset: { width: 0, height: 3 }, + elevation: 2, + }, + walletRowActive: { + backgroundColor: colors.tintedBackground, + borderColor: colors.primary, + }, + walletRowLast: { + marginBottom: 0, + }, + walletContent: { + flex: 1, + marginRight: 12, + }, + walletMeta: { + flexDirection: 'row', + alignItems: 'center', + }, + walletAvatar: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.card, + borderWidth: 1, + borderColor: colors.border, + marginRight: 12, + }, + walletAvatarText: { + color: colors.text, + fontSize: 14, + fontWeight: '600', + }, + walletText: { + flex: 1, + }, + walletTitleRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 8, + }, + walletName: { + fontSize: 16, + fontWeight: '600', + color: colors.text, + marginBottom: 2, + }, + walletAddressLabel: { + fontSize: 11, + color: colors.textSecondary, + textTransform: 'uppercase', + letterSpacing: 0.6, + marginTop: 2, + }, + walletAddressValue: { + fontSize: 12, + color: colors.textTertiary, + lineHeight: 16, + marginTop: 2, + }, + actionSlot: { + minWidth: 44, + alignItems: 'flex-end', + justifyContent: 'center', + }, + activeBadgeInline: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + backgroundColor: colors.primary, + borderRadius: 999, + paddingHorizontal: 10, + paddingVertical: 4, + }, + activeBadge: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + backgroundColor: colors.primary, + borderRadius: 999, + paddingHorizontal: 10, + paddingVertical: 4, + }, + activeLabel: { + fontSize: 12, + fontWeight: '600', + color: colors.black, + }, + emptyState: { + paddingVertical: 40, + alignItems: 'center', + }, + emptyStateText: { + fontSize: 16, + color: colors.text, + fontWeight: '500', + marginBottom: 8, + }, + emptyStateSubtext: { + fontSize: 14, + color: colors.textSecondary, + }, + footerActions: { + marginTop: 16, + gap: 12, + }, + primaryActionButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.primary, + borderRadius: 12, + paddingVertical: 14, + gap: 8, + }, + primaryActionText: { + fontSize: 16, + fontWeight: '600', + color: colors.black, + }, + secondaryActionButton: { + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.card, + borderRadius: 12, + paddingVertical: 14, + borderWidth: 1, + borderColor: colors.border, + }, + secondaryActionText: { + fontSize: 16, + fontWeight: '600', + color: colors.text, + }, +}); diff --git a/src/components/header.tsx b/src/components/header.tsx index b3c2d4b..d479839 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -15,10 +15,15 @@ interface HeaderProps { title: string; isLoading?: boolean; style?: StyleProp; + /** + * Optional wallet switcher trigger. + * If not provided, layout remains unchanged. + */ + onWalletPress?: () => void; } const Header = (params: HeaderProps) => { - const { title, isLoading = false, style } = params; + const { title, isLoading = false, style, onWalletPress } = params; const router = useDebouncedNavigation(); const handleBack = () => { @@ -27,10 +32,13 @@ const Header = (params: HeaderProps) => { return ( + {/* Back button */} Back + + {/* Title */} {title} {isLoading ? ( @@ -39,7 +47,15 @@ const Header = (params: HeaderProps) => { ) : null} - + + {/* Right action (wallet switcher or spacer) */} + {onWalletPress ? ( + + Wallet + + ) : ( + + )} ); }; @@ -65,24 +81,29 @@ const styles = StyleSheet.create({ fontSize: 16, marginLeft: 4, }, - title: { - fontSize: 18, - fontWeight: '600', - color: colors.text, - textAlign: 'center', - }, - spacer: { - width: 60, - }, titleContainer: { position: 'relative', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', }, + title: { + fontSize: 18, + fontWeight: '600', + color: colors.text, + textAlign: 'center', + }, loadingContainer: { position: 'absolute', top: 2, right: -28, }, + spacer: { + width: 60, + }, + walletText: { + color: colors.primary, + fontSize: 16, + fontWeight: '500', + }, }); diff --git a/src/config/assets.ts b/src/config/assets.ts index 7a86eaf..53bd737 100644 --- a/src/config/assets.ts +++ b/src/config/assets.ts @@ -21,6 +21,13 @@ export interface Asset { } export const assetConfig: Record = { + eth: { + name: 'Ethereum', + symbol: 'ETH', + icon: require('../../assets/images/chains/ethereum-eth-logo.png'), + color: '#ffffff', + supportedNetworks: [NetworkType.ETHEREUM], + }, btc: { name: 'Bitcoin', symbol: 'BTC', diff --git a/src/hooks/use-wallet-avatar.ts b/src/hooks/use-wallet-avatar.ts index b1e1b17..e084774 100644 --- a/src/hooks/use-wallet-avatar.ts +++ b/src/hooks/use-wallet-avatar.ts @@ -1,12 +1,14 @@ -import avatarOptions, { getAvatar } from '@/config/avatar-options'; -import { useEffect, useState } from 'react'; +import avatarOptions from '@/config/avatar-options'; +import { useWalletSwitcher } from '@/hooks/use-wallet-switcher'; +import { useMemo } from 'react'; const useWalletAvatar = () => { - const [avatar, setAvatar] = useState(avatarOptions[0].emoji); - - useEffect(() => { - getAvatar().then(avatar => setAvatar(avatar.emoji)); - }, []); + const { activeWallet } = useWalletSwitcher(); + const avatar = useMemo(() => { + const option = + avatarOptions.find((item) => item.id === activeWallet?.avatarId) ?? avatarOptions[0]; + return option.emoji; + }, [activeWallet?.avatarId]); return avatar; }; diff --git a/src/hooks/use-wallet-switcher.ts b/src/hooks/use-wallet-switcher.ts new file mode 100644 index 0000000..129550c --- /dev/null +++ b/src/hooks/use-wallet-switcher.ts @@ -0,0 +1,450 @@ +import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { useWallet } from '@tetherto/wdk-react-native-provider'; +import { Alert } from 'react-native'; +import { useDebouncedNavigation } from '@/hooks/use-debounced-navigation'; +import { + deleteWalletMnemonic, + getWalletMnemonic, + resetWalletMnemonics, +} from '../utils/wallet-secrets'; +import { + clearWallets, + getActiveWalletId, + getWallets, + removeWallet, + setActiveWalletId, + touchWallet, + updateWallet, +} from '../utils/wallet-storage'; +import { clearWalletCache } from '@/utils/wallet-cache'; + +/** + * Wallet Switcher Hook + * + * Responsibilities: + * - Control wallet switcher sheet visibility + * - Switch active wallet via WDK (single source of truth) + * + * Invariants: + * - No wallet state duplication + * - WDK remains authoritative for active wallet + */ +type WalletInfo = { + id: string; + name: string; + address?: string; + addresses?: Record; + avatarId?: number; +}; + +type WalletSwitcherContextValue = { + isOpen: boolean; + wallets: WalletInfo[]; + activeWallet?: WalletInfo; + isSwitchingWallet: boolean; + open: () => void; + close: () => void; + switchWallet: (walletId: string) => Promise; + deleteWallet: (walletId: string) => Promise; + resetWallets: () => Promise; +}; + +const WalletSwitcherContext = React.createContext( + undefined +); + +export function WalletSwitcherProvider({ children }: { children: React.ReactNode }) { + const [wallets, setWallets] = useState([]); + const [activeWalletId, setActiveWalletIdLocal] = useState(null); + const [isSwitchingWallet, setIsSwitchingWallet] = useState(false); + const { createWallet, clearWallet, addresses, wallet } = useWallet(); + const router = useDebouncedNavigation(); + const addressesRef = useRef(addresses); + const lastPrimaryAddressRef = useRef(undefined); + + const [isOpen, setIsOpen] = useState(false); + + const open = useCallback(() => { + setIsOpen(true); + }, []); + + const close = useCallback(() => { + setIsOpen(false); + }, []); + + useEffect(() => { + addressesRef.current = addresses; + }, [addresses]); + + const getPrimaryAddress = useCallback((value?: typeof addresses) => { + if (!value) return undefined; + const candidate = Object.values(value).find(Boolean); + return typeof candidate === 'string' ? candidate : undefined; + }, []); + + const getAddressMap = useCallback((value?: typeof addresses) => { + if (!value) return undefined; + const entries = Object.entries(value).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' && !!entry[1] + ); + return entries.length ? Object.fromEntries(entries) : undefined; + }, []); + + const areAddressMapsEqual = useCallback( + (left?: Record, right?: Record) => { + if (!left && !right) return true; + if (!left || !right) return false; + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + if (leftKeys.length !== rightKeys.length) return false; + return leftKeys.every((key) => left[key] === right[key]); + }, + [] + ); + + const waitForPrimaryAddress = useCallback( + async (previous?: string) => { + for (let attempt = 0; attempt < 20; attempt += 1) { + const next = getPrimaryAddress(addressesRef.current); + if (next && next !== previous) { + return next; + } + await new Promise((resolve) => setTimeout(resolve, 300)); + } + return getPrimaryAddress(addressesRef.current); + }, + [getPrimaryAddress] + ); + + const runWithSwitchingState = useCallback(async (action: () => Promise) => { + setIsSwitchingWallet(true); + try { + await action(); + } finally { + setIsSwitchingWallet(false); + } + }, []); + + const resetWallets = useCallback(async () => { + const walletIds = wallets.map((wallet) => wallet.id); + await resetWalletMnemonics(walletIds); + await clearWallets(); + await clearWallet(); + setWallets([]); + setActiveWalletIdLocal(null); + router.replace('/onboarding'); + }, [clearWallet, router, wallets]); + + const handleBiometryNotEnrolled = useCallback(() => { + Alert.alert( + 'Biometrics Not Enrolled', + 'This wallet was saved with biometric-only protection. To continue on this device, reset secure storage and re-import your wallets.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Reset Wallets', + style: 'destructive', + onPress: async () => { + try { + await resetWallets(); + } catch (error) { + console.error('[wallet-switcher] failed to reset wallets', error); + } + }, + }, + ] + ); + }, [resetWallets]); + + const loadWalletById = useCallback( + async (walletId: string, options?: { closeOnFinish?: boolean }) => { + const closeOnFinish = options?.closeOnFinish ?? true; + if (!walletId || walletId === activeWalletId) { + if (closeOnFinish) close(); + return; + } + + const wallet = wallets.find((item) => item.id === walletId); + if (!wallet) { + throw new Error('Wallet not found'); + } + + let mnemonic: string; + try { + mnemonic = await getWalletMnemonic(walletId); + } catch (error) { + const message = error instanceof Error ? error.message.toLowerCase() : ''; + if (message.includes('no fingerprints enrolled')) { + await handleBiometryNotEnrolled(); + return; + } + throw error; + } + const previousAddress = getPrimaryAddress(addressesRef.current); + const previousAddressMap = getAddressMap(addressesRef.current); + await clearWalletCache(); + const createdWallet = await createWallet({ name: wallet.name, mnemonic }); + await setActiveWalletId(walletId); + setActiveWalletIdLocal(walletId); + await touchWallet(walletId); + setWallets((prev) => + prev.map((item) => + item.id === walletId + ? { + ...item, + name: createdWallet?.name || item.name, + } + : item + ) + ); + const updateAddressesAsync = async () => { + const nextAddress = await waitForPrimaryAddress(previousAddress); + const addressMap = getAddressMap(addressesRef.current); + // Only persist addresses when they change to prevent writing stale data. + const addressesChanged = + (nextAddress && nextAddress !== previousAddress) || + !areAddressMapsEqual(previousAddressMap, addressMap); + if (!addressesChanged || (!nextAddress && !addressMap)) { + return; + } + + const storedActiveId = await getActiveWalletId(); + if (storedActiveId !== walletId) { + return; + } + + await updateWallet(walletId, { address: nextAddress, addresses: addressMap }).catch( + (error) => { + console.error('[wallet-switcher] failed to update address after switch', error); + } + ); + + setWallets((prev) => + prev.map((item) => + item.id === walletId + ? { + ...item, + address: nextAddress || item.address, + addresses: addressMap ?? item.addresses, + } + : item + ) + ); + }; + + updateAddressesAsync().catch((error) => { + console.error('[wallet-switcher] address update failed', error); + }); + if (closeOnFinish) close(); + }, + [ + activeWalletId, + close, + createWallet, + getAddressMap, + getPrimaryAddress, + handleBiometryNotEnrolled, + wallets, + waitForPrimaryAddress, + ] + ); + + const switchWallet = useCallback( + async (walletId: string) => { + const previousActiveId = activeWalletId; + await runWithSwitchingState(async () => { + // Optimistically update selection for instant UI feedback. + if (walletId && walletId !== activeWalletId) { + setActiveWalletIdLocal(walletId); + } + close(); + try { + await loadWalletById(walletId, { closeOnFinish: false }); + } catch (error) { + if (previousActiveId) { + setActiveWalletIdLocal(previousActiveId); + } + throw error; + } + }); + }, + [activeWalletId, close, loadWalletById, runWithSwitchingState] + ); + + const deleteWallet = useCallback( + async (walletId: string) => { + const previousWallets = wallets; + const previousActiveId = activeWalletId; + await runWithSwitchingState(async () => { + const remaining = wallets.filter((wallet) => wallet.id !== walletId); + const nextActiveId = remaining[0]?.id || null; + // Optimistically remove from UI to avoid lag. + setWallets(remaining); + if (walletId === activeWalletId) { + setActiveWalletIdLocal(nextActiveId); + } + + try { + if (walletId === activeWalletId) { + if (remaining.length > 0) { + await loadWalletById(remaining[0].id, { closeOnFinish: false }); + } else { + await clearWallet(); + await setActiveWalletId(''); + setActiveWalletIdLocal(null); + } + } + + await deleteWalletMnemonic(walletId); + const updated = await removeWallet(walletId); + setWallets(updated); + } catch (error) { + // Restore previous state on failure. + setWallets(previousWallets); + if (previousActiveId) { + setActiveWalletIdLocal(previousActiveId); + } + throw error; + } + }); + }, + [activeWalletId, clearWallet, loadWalletById, runWithSwitchingState, wallets] + ); + + const loadWallets = useCallback(async () => { + let storedWallets = await getWallets(); + if (!storedWallets.length) { + await new Promise((resolve) => setTimeout(resolve, 300)); + storedWallets = await getWallets(); + } + const storedActiveId = await getActiveWalletId(); + const sortedWallets = [...storedWallets].sort((a, b) => b.lastUsedAt - a.lastUsedAt); + const hasStoredActive = sortedWallets.some((wallet) => wallet.id === storedActiveId); + const nextActiveId = hasStoredActive ? storedActiveId : sortedWallets[0]?.id || null; + setWallets(sortedWallets); + setActiveWalletIdLocal(nextActiveId); + if (!sortedWallets.length) { + await setActiveWalletId(''); + router.replace('/onboarding'); + } + if (__DEV__) { + console.info('[wallet-switcher] loaded wallets', { + count: sortedWallets.length, + activeId: nextActiveId, + ids: sortedWallets.map((wallet) => wallet.id), + }); + } + }, [router]); + + useEffect(() => { + loadWallets(); + }, [loadWallets]); + + useEffect(() => { + if (wallet) { + loadWallets(); + } + }, [wallet, loadWallets]); + + useEffect(() => { + if (isOpen) { + loadWallets(); + } + }, [isOpen, loadWallets]); + + const primaryAddress = useMemo(() => getPrimaryAddress(addresses), [addresses, getPrimaryAddress]); + + useEffect(() => { + if (!activeWalletId || !primaryAddress) return; + let cancelled = false; + const syncAddress = async () => { + const storedActiveId = await getActiveWalletId(); + if (cancelled) return; + if (!storedActiveId || storedActiveId !== activeWalletId) { + return; + } + if (primaryAddress === lastPrimaryAddressRef.current) return; + const currentWallet = wallets.find((wallet) => wallet.id === activeWalletId); + if ( + wallet?.name && + currentWallet?.name && + wallet.name !== currentWallet.name && + (currentWallet.address || currentWallet.addresses) + ) { + // Addresses belong to a different active wallet; skip sync to avoid overwriting. + return; + } + if (currentWallet?.address === primaryAddress) { + lastPrimaryAddressRef.current = primaryAddress; + return; + } + const addressMap = getAddressMap(addresses); + // Only persist addresses when they change to prevent writing stale data. + const addressesChanged = + (primaryAddress && primaryAddress !== currentWallet?.address) || + !areAddressMapsEqual(currentWallet?.addresses, addressMap); + if (!addressesChanged) { + return; + } + + updateWallet(activeWalletId, { address: primaryAddress, addresses: addressMap }).catch( + (error) => { + console.error('[wallet-switcher] failed to update address', error); + } + ); + + setWallets((prev) => + prev.map((wallet) => + wallet.id === activeWalletId + ? { ...wallet, address: primaryAddress, addresses: addressMap ?? wallet.addresses } + : wallet + ) + ); + lastPrimaryAddressRef.current = primaryAddress; + }; + syncAddress(); + return () => { + cancelled = true; + }; + }, [activeWalletId, addresses, getAddressMap, primaryAddress, wallets]); + + const activeWallet = useMemo( + () => wallets.find((wallet) => wallet.id === activeWalletId), + [wallets, activeWalletId] + ); + + const value = useMemo( + () => ({ + isOpen, + wallets, + activeWallet, + isSwitchingWallet, + open, + close, + switchWallet, + deleteWallet, + resetWallets, + }), + [ + isOpen, + wallets, + activeWallet, + isSwitchingWallet, + open, + close, + switchWallet, + deleteWallet, + resetWallets, + ] + ); + + return React.createElement(WalletSwitcherContext.Provider, { value }, children); +} + +export function useWalletSwitcher() { + const context = useContext(WalletSwitcherContext); + if (!context) { + throw new Error('useWalletSwitcher must be used within WalletSwitcherProvider'); + } + return context; +} diff --git a/src/utils/ensure-device-auth.ts b/src/utils/ensure-device-auth.ts new file mode 100644 index 0000000..cfc875a --- /dev/null +++ b/src/utils/ensure-device-auth.ts @@ -0,0 +1,26 @@ +import * as Keychain from 'react-native-keychain'; + +export const ensureDeviceAuthentication = async () => { + const biometryType = await Keychain.getSupportedBiometryType(); + const accessControl = biometryType + ? Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET_OR_DEVICE_PASSCODE + : Keychain.ACCESS_CONTROL.DEVICE_PASSCODE; + + await Keychain.setGenericPassword('wdk', 'seed', { + accessControl, + accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED, + }); + + const authResult = await Keychain.getGenericPassword({ + authenticationPrompt: { + title: 'Use Face/Touch ID or device PIN', + subtitle: 'Confirm your identity to continue', + }, + }); + + if (!authResult) { + throw new Error('User not authenticated'); + } + + return true; +}; diff --git a/src/utils/get-error-message.ts b/src/utils/get-error-message.ts index 88aad2e..c15b19b 100644 --- a/src/utils/get-error-message.ts +++ b/src/utils/get-error-message.ts @@ -3,7 +3,25 @@ import parseWorkletError from './parse-worklet-error'; const getErrorMessage = (error: unknown, fallbackMessage: string) => { const workletError = parseWorkletError(error); if (workletError) return workletError.message; - if (error instanceof Error) return error.message; + if (error instanceof Error) { + if (error.message.toLowerCase().includes('biometric not enrolled')) { + return 'Face/Touch ID is not enrolled. Enable biometrics or a device screen lock (PIN/password) and try again.'; + } + if (error.message.toLowerCase().includes('no fingerprints enrolled')) { + return 'No fingerprints are enrolled. Enable device biometrics or screen lock, then reinstall the app to refresh secure storage.'; + } + if (error.message.toLowerCase().includes('user not authenticated')) { + return 'Device security is required. Unlock your device or enable a PIN/biometric, then try again.'; + } + if ( + error.message.toLowerCase().includes('keyguard') || + error.message.toLowerCase().includes('device is not secure') || + error.message.toLowerCase().includes('lock screen') + ) { + return 'Device lock is not set. Enable a screen lock (PIN/password) to continue.'; + } + return error.message; + } return fallbackMessage; }; diff --git a/src/utils/wallet-cache.ts b/src/utils/wallet-cache.ts new file mode 100644 index 0000000..e877cd1 --- /dev/null +++ b/src/utils/wallet-cache.ts @@ -0,0 +1,13 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const STORAGE_KEY_ADDRESSES = 'wdk_wallet_addresses'; +const STORAGE_KEY_BALANCES = 'wdk_wallet_balances'; +const STORAGE_KEY_TRANSACTIONS = 'wdk_wallet_transactions'; + +export const clearWalletCache = async () => { + await AsyncStorage.multiRemove([ + STORAGE_KEY_ADDRESSES, + STORAGE_KEY_BALANCES, + STORAGE_KEY_TRANSACTIONS, + ]); +}; diff --git a/src/utils/wallet-secrets.ts b/src/utils/wallet-secrets.ts new file mode 100644 index 0000000..9c6b8e1 --- /dev/null +++ b/src/utils/wallet-secrets.ts @@ -0,0 +1,35 @@ +import * as Keychain from 'react-native-keychain'; + +const getMnemonicServiceName = (walletId: string) => `wdk-wallet-${walletId}`; + +export const saveWalletMnemonic = async (walletId: string, mnemonic: string) => { + await Keychain.setGenericPassword('wallet', mnemonic, { + service: getMnemonicServiceName(walletId), + accessControl: Keychain.ACCESS_CONTROL.DEVICE_PASSCODE, + accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED, + }); +}; + +export const getWalletMnemonic = async (walletId: string) => { + const result = await Keychain.getGenericPassword({ + service: getMnemonicServiceName(walletId), + authenticationPrompt: { + title: 'Unlock wallet', + subtitle: 'Confirm your identity to switch wallets', + }, + }); + + if (!result) { + throw new Error('User not authenticated'); + } + + return result.password; +}; + +export const deleteWalletMnemonic = async (walletId: string) => { + await Keychain.resetGenericPassword({ service: getMnemonicServiceName(walletId) }); +}; + +export const resetWalletMnemonics = async (walletIds: string[]) => { + await Promise.all(walletIds.map((walletId) => deleteWalletMnemonic(walletId))); +}; diff --git a/src/utils/wallet-storage.ts b/src/utils/wallet-storage.ts new file mode 100644 index 0000000..0b69161 --- /dev/null +++ b/src/utils/wallet-storage.ts @@ -0,0 +1,133 @@ +import * as Keychain from 'react-native-keychain'; +import { nanoid } from 'nanoid/non-secure'; + +export type StoredWallet = { + id: string; + name: string; + address?: string; + addresses?: Record; + avatarId?: number; + createdAt: number; + lastUsedAt: number; +}; + +const STORAGE_SERVICE_WALLET_META = 'wdk_wallets_meta'; +const STORAGE_SERVICE_ACTIVE_WALLET_ID = 'wdk_active_wallet_id'; + +const storeWalletValue = async (service: string, value: string) => { + try { + await Keychain.setGenericPassword('wallets', value, { + service, + accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED, + }); + } catch (error) { + console.error('Error saving secure value:', error); + } +}; + +const readWalletValue = async (service: string): Promise => { + try { + const result = await Keychain.getGenericPassword({ service }); + return result ? result.password : null; + } catch (error) { + console.error('Error loading secure value:', error); + return null; + } +}; + +const saveWallets = async (wallets: StoredWallet[]) => { + await storeWalletValue(STORAGE_SERVICE_WALLET_META, JSON.stringify(wallets)); +}; + +export const getWallets = async (): Promise => { + try { + const stored = await readWalletValue(STORAGE_SERVICE_WALLET_META); + const parsed = stored ? JSON.parse(stored) : []; + if (__DEV__) { + console.info('[wallet-storage] getWallets', { + count: Array.isArray(parsed) ? parsed.length : 0, + raw: stored, + }); + } + return parsed; + } catch (error) { + console.error('Error loading wallets:', error); + return []; + } +}; + +export const setActiveWalletId = async (walletId: string) => { + if (!walletId) { + await Keychain.resetGenericPassword({ service: STORAGE_SERVICE_ACTIVE_WALLET_ID }); + return; + } + await storeWalletValue(STORAGE_SERVICE_ACTIVE_WALLET_ID, walletId); +}; + +export const getActiveWalletId = async (): Promise => { + return readWalletValue(STORAGE_SERVICE_ACTIVE_WALLET_ID); +}; + +export const createWalletEntry = ( + name: string, + address?: string, + avatarId?: number +): StoredWallet => { + const now = Date.now(); + const id = nanoid(); + return { + id, + name, + address, + avatarId, + createdAt: now, + lastUsedAt: now, + }; +}; + +export const addWallet = async (wallet: StoredWallet): Promise => { + const wallets = await getWallets(); + const updated = [wallet, ...wallets.filter((item) => item.id !== wallet.id)]; + await saveWallets(updated); + if (__DEV__) { + console.info('[wallet-storage] addWallet', { + addedId: wallet.id, + total: updated.length, + }); + } + return updated; +}; + +export const updateWallet = async ( + walletId: string, + updates: Partial> +): Promise => { + const wallets = await getWallets(); + const updated = wallets.map((wallet) => + wallet.id === walletId ? { ...wallet, ...updates } : wallet + ); + await saveWallets(updated); + return updated; +}; + +export const touchWallet = async (walletId: string): Promise => { + return updateWallet(walletId, { lastUsedAt: Date.now() }); +}; + +export const removeWallet = async (walletId: string): Promise => { + const wallets = await getWallets(); + const updated = wallets.filter((wallet) => wallet.id !== walletId); + await saveWallets(updated); + if (__DEV__) { + console.info('[wallet-storage] removeWallet', { + removedId: walletId, + total: updated.length, + }); + } + return updated; +}; + +export const clearWallets = async () => { + await Keychain.resetGenericPassword({ service: STORAGE_SERVICE_WALLET_META }); + await Keychain.resetGenericPassword({ service: STORAGE_SERVICE_ACTIVE_WALLET_ID }); +}; diff --git a/test/e2e/pageObjects/home-onboarding-screen.ts b/test/e2e/pageObjects/home-onboarding-screen.ts index b080e1b..5d487bc 100644 --- a/test/e2e/pageObjects/home-onboarding-screen.ts +++ b/test/e2e/pageObjects/home-onboarding-screen.ts @@ -5,14 +5,14 @@ export class HomeOnboardingScreen { * Gets the title and subtitle welcome message elements * Uses text selector since these are TextView elements without content-desc */ - async getTitleAndSubtitleWelcomeMessage() { + async getTitleAndSubtitleWelcomeMessage(timeoutMs = 15000) { // Use text selector for TextView elements const titleElement = $('android=new UiSelector().text("Welcome!")'); const subtitleElement = $('android=new UiSelector().text("Set up your wallet and start exploring the crypto world.")'); // Wait for elements to be displayed - await titleElement.waitForDisplayed({ timeout: 5000 }); - await subtitleElement.waitForDisplayed({ timeout: 5000 }); + await titleElement.waitForDisplayed({ timeout: timeoutMs }); + await subtitleElement.waitForDisplayed({ timeout: timeoutMs }); // Get text from elements const titleText = await titleElement.getText();