diff --git a/package.json b/package.json index 5cd22cb3..f53698b4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "minibits_wallet", - "version": "0.3.4-beta.17", + "version": "0.3.4-beta.18", "private": true, "scripts": { "android:clean": "cd android && ./gradlew clean", diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 10a4e498..348822ef 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -5,12 +5,18 @@ import { TouchableOpacity, TouchableOpacityProps, View, - ViewStyle, + ViewStyle, StatusBar, StatusBarProps, ColorValue, Platform } from "react-native" +import Animated, { + SharedValue, + useAnimatedStyle, + interpolate, + Extrapolation, +} from "react-native-reanimated" import { useIsFocused } from '@react-navigation/native' import useIsInternetReachable from '../utils/useIsInternetReachable' import { translate } from "../i18n" @@ -140,6 +146,16 @@ export interface HeaderProps { * Pass any additional props directly to the StatusBar component. */ StatusBarProps?: StatusBarProps + /** + * Shared value from scroll position for animated title. + * When provided, the title will fade in as the user scrolls. + */ + scrollY?: SharedValue + /** + * The scroll distance over which the title animation occurs. + * Defaults to 60. + */ + scrollDistance?: number } interface HeaderActionProps { @@ -198,11 +214,27 @@ export function Header(props: HeaderProps) { titleStyle: $titleStyleOverride, containerStyle: $containerStyleOverride, StatusBarProps, + scrollY, + scrollDistance = 60, } = props const $containerInsets = useSafeAreaInsetsStyle(safeAreaEdges) const titleContent = !!TitleActionComponent ? undefined : titleTx ? translate(titleTx, titleTxOptions) : title + // Animated title opacity - fades in as user scrolls + const animatedTitleStyle = useAnimatedStyle(() => { + if (!scrollY) { + return {} + } + const opacity = interpolate( + scrollY.value, + [0, scrollDistance], + [0, 1], + Extrapolation.CLAMP + ) + return { opacity } + }) + return ( )} - {!!titleContent && ( - @@ -247,7 +280,7 @@ export function Header(props: HeaderProps) { text={titleContent} style={[$title, $titleStyleOverride]} /> - + )} ).NOSTR + const keys: NostrKeyPair = (yield KeyChain.getWalletKeys()).NOSTR log.debug('[publishToRelays]', 'Publish profile to relays', {profileEvent, relaysToPublish}) @@ -90,8 +91,9 @@ export const WalletProfileStoreModel = types } catch (e: any) { // on profile creation / recovery this fails at first as nostr keys are not yet saved - log.warn(e.name, e.message) - return false // silent + log.warn(e.name, e.message) + return false + // silent } }) })) @@ -106,7 +108,7 @@ export const WalletProfileStoreModel = types self.pubkey = pubkey self.walletId = walletId - yield self.publishToRelays() + self.publishToRelays() }) })) .actions(self => ({ @@ -121,7 +123,7 @@ export const WalletProfileStoreModel = types profileRecord = yield MinibitsClient.createWalletProfile(walletId, seedHash) self.hydrate(profileRecord) - log.info('[create]', 'Wallet profile saved in WalletProfileStore', {self}) + log.info('[create] ', 'Wallet profile saved in WalletProfileStore', {self}) return self } catch (e: any) { // Unlikely we might hit the same walletId so we retry with another one @@ -131,7 +133,7 @@ export const WalletProfileStoreModel = types // attempt to create new unique profile again profileRecord = yield MinibitsClient.createWalletProfile(name, seedHash) - log.error('[create]', 'Profile reset executed to resolve duplicate walletId on the server.', {caller: 'create', walletId, newWalletId: name}) + log.error('[create] ', 'Profile reset executed to resolve duplicate walletId on the server.', {caller: 'create', walletId, newWalletId: name}) self.hydrate(profileRecord) return self @@ -149,7 +151,7 @@ export const WalletProfileStoreModel = types self.hydrate(profileRecord) - log.debug('[updateName]', 'Wallet name updated in the WalletProfileStore', {self}) + log.debug('[updateName] ', 'Wallet name updated in the WalletProfileStore', {self}) return self }), updatePicture: flow(function* updatePicture(picture: string) { @@ -164,7 +166,7 @@ export const WalletProfileStoreModel = types const publishedEvent = yield self.publishToRelays() - log.debug('[updatePicture]', 'Wallet picture updated in the WalletProfileStore', {self, publishedEvent}) + log.debug('[updatePicture] ', 'Wallet picture updated in the WalletProfileStore', {self, publishedEvent}) return self }), updateNip05: flow(function* updateNip05(newPubkey: string, name: string, nip05: string, lud16: string, picture: string, isOwnProfile: boolean) { @@ -181,35 +183,35 @@ export const WalletProfileStoreModel = types } ) - log.trace('[updateNip05]', 'profileRecord', {profileRecord}) + log.trace('[updateNip05] ', 'profileRecord', {profileRecord}) self.hydrate(profileRecord) self.isOwnProfile = isOwnProfile - log.info('[updateNip05]', 'Wallet nip05 updated in the WalletProfileStore', {self}) + log.info('[updateNip05] ', 'Wallet nip05 updated in the WalletProfileStore', {self}) return self }), - recover: flow(function* recover(walletId: string, seedHash: string) { + recover: flow(function* recover(walletId: string, seedHash: string, newPubkey?: string) { let profileRecord: WalletProfileRecord = yield MinibitsClient.recoverProfile( - walletId, seedHash - ) - + walletId, seedHash, newPubkey + ) + self.hydrate(profileRecord) - log.info('[recover]', 'Wallet profile recovered in WalletProfileStore', {self}) - return self + log.info('[recover] ', 'Wallet profile recovered in WalletProfileStore', {self, pubkeyRotated: !!newPubkey}) + return self }), - recoverAddress: flow(function* recover(walletId: string, seedHash: string) { + recoverAddress: flow(function* recoverAddress(walletId: string, seedHash: string, newPubkey?: string) { let profileRecord: WalletProfileRecord = yield MinibitsClient.recoverAddress( - walletId, seedHash - ) - + walletId, seedHash, newPubkey + ) + self.hydrate(profileRecord) - log.info('[recover]', 'Wallet profile recovered in WalletProfileStore', {self}) - return self + log.info('[recoverAddress] ', 'Wallet address recovered in WalletProfileStore', {self, pubkeyRotated: !!newPubkey}) + return self }), setDevice: flow(function* setDevice(device: string) { try { @@ -221,7 +223,7 @@ export const WalletProfileStoreModel = types yield MinibitsClient.updateDeviceToken(device) self.device = device } catch (e: any) { - log.error('[setDevice]', e.message) + log.error('[setDevice] ', e.message) } }) })) diff --git a/src/screens/ImportBackupScreen.tsx b/src/screens/ImportBackupScreen.tsx index 6234022b..f2b23bdc 100644 --- a/src/screens/ImportBackupScreen.tsx +++ b/src/screens/ImportBackupScreen.tsx @@ -231,12 +231,15 @@ export const ImportBackupScreen = observer(function ImportBackupScreen({ route } if(!seedHashRef.current || !seedRef.current) { throw new AppError(Err.VALIDATION_ERROR, translate('backupMissingMnemonicOrSeedError')) } - // create a new walletId and a new Nostr key pair - // and keep provided seed + // Derive Nostr keys from the recovered mnemonic and keep provided seed setIsLoading(true) setStatusMessage(translate("recovery_recoveringAddress")) const keys = await walletStore.getCachedWalletKeys() + + // Derive Nostr keypair from the recovered mnemonic (NIP-06) + const nostrKeys = KeyChain.deriveNostrKeyPair(mnemonic) + // Set seed to the provided one const seed = { seed: Buffer.from(seedRef.current).toString('base64'), @@ -246,30 +249,37 @@ export const ImportBackupScreen = observer(function ImportBackupScreen({ route } // In case there is a profile linked to provided seedHash, // it's address, avatar and seed is recovered to the current profile. + // Pass newPubkey so server rotates the profile to the derived pubkey. await walletProfileStore.recover( - keys.walletId, + keys.walletId, seedHashRef.current, + nostrKeys.publicKey ) - // update seed to the provided one + // Update both seed and Nostr keys to derived values const keysCopy = { ...keys } keysCopy.SEED = seed + keysCopy.NOSTR = nostrKeys - await KeyChain.saveWalletKeys(keysCopy) + await KeyChain.saveWalletKeys(keysCopy) walletStore.cleanCachedWalletKeys() + // Re-authenticate with new derived keys to get fresh JWT tokens + await authStore.clearTokens() + await authStore.enrollDevice(nostrKeys) + if(!mintsStore.mintExists(MINIBITS_MINT_URL)) { - await mintsStore.addMint(MINIBITS_MINT_URL) + await mintsStore.addMint(MINIBITS_MINT_URL) } setStatusMessage(translate('recovery_completed')) - - // go directly to the wallet (profile hase been rehydrated from the one with the seed) + + // go directly to the wallet (profile has been rehydrated from the one with the seed) //@ts-ignore navigation.navigate('Tabs') await delay(1000) setStatusMessage('') - setIsLoading(false) + setIsLoading(false) } catch (e: any) { handleError(e) } diff --git a/src/screens/OwnKeysScreen.tsx b/src/screens/OwnKeysScreen.tsx index b9895b1a..73d2ea72 100644 --- a/src/screens/OwnKeysScreen.tsx +++ b/src/screens/OwnKeysScreen.tsx @@ -24,7 +24,7 @@ type Props = StaticScreenProps<{}> export const OwnKeysScreen = observer(function OwnKeysScreen({ route }: Props) { const navigation = useNavigation() - const {walletProfileStore, walletStore, relaysStore} = useStores() + const {walletProfileStore, walletStore, relaysStore, authStore} = useStores() useHeader({ leftIcon: 'faArrowLeft', @@ -190,17 +190,17 @@ export const OwnKeysScreen = observer(function OwnKeysScreen({ route }: Props) { } setIsLoading(true) - // update wallet profile - const updatedProfile = await walletProfileStore.updateNip05( + // update wallet profile on server + await walletProfileStore.updateNip05( ownProfile.pubkey, ownProfile.name as string, - ownProfile.nip05 as string, + ownProfile.nip05 as string, ownProfile.lud16 || '', ownProfile.picture as string, - true // isOwnProfile + true // isOwnProfile ) - // update Nostr keys + // update Nostr keys locally const cachedKeys = await walletStore.getCachedWalletKeys() const keys = { ...cachedKeys } // Create a shallow copy to avoid modifying readonly properties keys['NOSTR'] = ownKeyPair @@ -208,9 +208,13 @@ export const OwnKeysScreen = observer(function OwnKeysScreen({ route }: Props) { await KeyChain.saveWalletKeys(keys) walletStore.cleanCachedWalletKeys() + // Re-authenticate with new keys to get fresh JWT tokens + await authStore.clearTokens() + await authStore.enrollDevice(ownKeyPair) + setIsLoading(false) setIsProfileChangeCompleted(true) - + } catch(e: any) { handleError(e) } diff --git a/src/screens/PrivacyScreen.tsx b/src/screens/PrivacyScreen.tsx index 117d9963..a95a4c10 100644 --- a/src/screens/PrivacyScreen.tsx +++ b/src/screens/PrivacyScreen.tsx @@ -16,7 +16,7 @@ import { import {useHeader} from '../utils/useHeader' import {useStores} from '../models' import AppError from '../utils/AppError' -import { KeyChain, MinibitsClient, } from '../services' +import { KeyChain, MinibitsClient, log } from '../services' import { MINIBITS_NIP05_DOMAIN } from '@env' import { StackActions, StaticScreenProps, useNavigation } from '@react-navigation/native' import { translate } from '../i18n' @@ -32,13 +32,30 @@ export const PrivacyScreen = observer(function PrivacyScreen({ route }: Props) { } }) - const {userSettingsStore, walletProfileStore, walletStore} = useStores() + const {userSettingsStore, walletProfileStore, walletStore, authStore} = useStores() const [info, setInfo] = useState('') const [isLoading, setIsLoading] = useState(false) + const [statusMessage, setStatusMessage] = useState('') const [isLoggerOn, setIsLoggerOn] = useState( userSettingsStore.isLoggerOn, ) - const [error, setError] = useState() + const [isDerivedKeys, setIsDerivedKeys] = useState(false) + const [error, setError] = useState() + + // Check if Nostr keys are derived from seed on mount + useEffect(() => { + const checkDerivedKeys = async () => { + try { + const keys = await walletStore.getCachedWalletKeys() + if (keys) { + setIsDerivedKeys(KeyChain.areNostrKeysDerived(keys)) + } + } catch (e) { + log.error('[PrivacyScreen] Error checking derived keys', e) + } + } + checkDerivedKeys() + }, []) const toggleLoggerSwitch = async () => { try { @@ -47,7 +64,55 @@ export const PrivacyScreen = observer(function PrivacyScreen({ route }: Props) { ) setIsLoggerOn(result) - + + } catch (e: any) { + handleError(e) + } + } + + const upgradeToNostrDerivedKeys = async () => { + setIsLoading(true) + setStatusMessage(translate('privacyScreen_upgradingKeys')) + + try { + const keys = await walletStore.getCachedWalletKeys() + if (!keys || !keys.SEED?.mnemonic) { + throw new Error('Wallet keys or mnemonic not available') + } + + // Derive Nostr keys from mnemonic using NIP-06 + const derivedNostrKeys = KeyChain.deriveNostrKeyPair(keys.SEED.mnemonic) + + log.trace('[upgradeToNostrDerivedKeys]', { + oldPubkey: keys.NOSTR.publicKey, + newPubkey: derivedNostrKeys.publicKey + }) + + // Update server profile with the new derived pubkey + // Uses recover endpoint with same seedHash to atomically rotate pubkey + await walletProfileStore.recover( + keys.walletId, + keys.SEED.seedHash, + derivedNostrKeys.publicKey + ) + + // Update local keys + const keysCopy = { ...keys } + keysCopy.NOSTR = derivedNostrKeys + + await KeyChain.saveWalletKeys(keysCopy) + walletStore.cleanCachedWalletKeys() + + // Re-authenticate with new derived keys to get fresh JWT tokens + await authStore.clearTokens() + await authStore.enrollDevice(derivedNostrKeys) + + // Update state + setIsDerivedKeys(true) + setStatusMessage('') + setIsLoading(false) + setInfo(translate('privacyScreen_derivedKeysDescription')) + } catch (e: any) { handleError(e) } @@ -59,16 +124,20 @@ export const PrivacyScreen = observer(function PrivacyScreen({ route }: Props) { } const resetProfile = async function() { - setIsLoading(true) + setIsLoading(true) try { - // overwrite with new keys - const newKeyPair = KeyChain.generateNostrKeyPair() + const cachedKeys = await walletStore.getCachedWalletKeys() + if (!cachedKeys || !cachedKeys.SEED?.mnemonic) { + throw new Error('Wallet keys or mnemonic not available') + } + + // Derive Nostr keys from mnemonic (NIP-06) instead of generating random keys + const derivedKeyPair = KeyChain.deriveNostrKeyPair(cachedKeys.SEED.mnemonic) // update Nostr keys - const cachedKeys = await walletStore.getCachedWalletKeys() - const keys = { ...cachedKeys } // Create a shallow copy to avoid modifying readonly properties - keys['NOSTR'] = newKeyPair + const keys = { ...cachedKeys } + keys['NOSTR'] = derivedKeyPair await KeyChain.saveWalletKeys(keys) walletStore.cleanCachedWalletKeys() @@ -79,15 +148,23 @@ export const PrivacyScreen = observer(function PrivacyScreen({ route }: Props) { // get random image const pictures = await MinibitsClient.getRandomPictures() // TODO PERF - // update wallet profile + // update wallet profile on server await walletProfileStore.updateNip05( - newKeyPair.publicKey, + derivedKeyPair.publicKey, name, name + MINIBITS_NIP05_DOMAIN, // nip05 name + MINIBITS_NIP05_DOMAIN, // lud16 pictures[0], false // isOwnProfile ) + + // Re-authenticate with derived keys to get fresh JWT tokens + await authStore.clearTokens() + await authStore.enrollDevice(derivedKeyPair) + + // Update derived keys state + setIsDerivedKeys(true) + // @ts-ignore navigation.navigate('ContactsNavigator', { screen: 'Profile' @@ -229,7 +306,46 @@ export const PrivacyScreen = observer(function PrivacyScreen({ route }: Props) { + <> + + + + ) : ( +