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();