Nostr keys derived from the cashu seed phrase

This commit is contained in:
minibits-cash
2026-01-26 21:08:38 +01:00
parent 9c3cc83601
commit af7d376978
15 changed files with 472 additions and 176 deletions
+1 -1
View File
@@ -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",
+38 -5
View File
@@ -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<number>
/**
* 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 (
<View style={[$container, $containerInsets, { backgroundColor }, $containerStyleOverride]}>
<FocusAwareStatusBar
@@ -232,12 +264,13 @@ export function Header(props: HeaderProps) {
{TitleActionComponent}
</View>
)}
{!!titleContent && (
<View
{!!titleContent && (
<Animated.View
style={[
titleMode === "center" && $titleWrapperCenter,
titleMode === "flex" && $titleWrapperFlex,
$titleContainerStyleOverride,
scrollY ? animatedTitleStyle : undefined,
]}
pointerEvents="none"
>
@@ -247,7 +280,7 @@ export function Header(props: HeaderProps) {
text={titleContent}
style={[$title, $titleStyleOverride]}
/>
</View>
</Animated.View>
)}
<HeaderAction
tx={rightTx}
@@ -315,7 +348,7 @@ const $container: ViewStyle = {
const $title: TextStyle = {
textAlign: "center",
fontFamily: typography.logo.normal,
//fontFamily: typography.logo.normal,
color: 'white'
}
+5
View File
@@ -321,6 +321,11 @@
"privacyScreen_torDaemon": "Use Tor network",
"privacyScreen_torDaemonDescription": "Allow connections with mints through Tor .onion addresses.",
"privacyScreen_torStatus": "Tor status",
"privacyScreen_derivedKeys": "Nostr keys derived from seed",
"privacyScreen_derivedKeysDescription": "Your Nostr keys are deterministically derived from your wallet seed phrase. This is now default behavior on Minibits.",
"privacyScreen_legacyKeysDescription": "Your wallet uses random Nostr keys. Upgrade to derive them from your seed for easier recovery.",
"privacyScreen_upgradeKeys": "Upgrade",
"privacyScreen_upgradingKeys": "Upgrading Nostr keys...",
"privateKey": "Private key",
"privateNotePlaceholder": "Your private note",
"profileMissingAddressError": "Missing address",
+5
View File
@@ -320,6 +320,11 @@
"privacyScreen_torDaemon": "Utilice la red Tor",
"privacyScreen_torDaemonDescription": "Permitir conexiones con mints a través de direcciones .onion de Tor.",
"privacyScreen_torStatus": "Estado de Tor",
"privacyScreen_derivedKeys": "Claves Nostr derivadas de la semilla",
"privacyScreen_derivedKeysDescription": "Tus claves Nostr se derivan de forma determinista de tu frase semilla.",
"privacyScreen_legacyKeysDescription": "Tu billetera usa claves Nostr aleatorias. Actualiza para derivarlas de tu semilla para una recuperación más fácil.",
"privacyScreen_upgradeKeys": "Actualizar",
"privacyScreen_upgradingKeys": "Actualizando claves Nostr...",
"privateKey": "Clave privada",
"privateNotePlaceholder": "Tu nota privada",
"profileMissingAddressError": "Dirección faltante",
+5
View File
@@ -321,6 +321,11 @@
"privacyScreen_torDaemon": "Usar rede Tor",
"privacyScreen_torDaemonDescription": "Permitir conexões com mints via endereços onion Tor.",
"privacyScreen_torStatus": "Status do Tor",
"privacyScreen_derivedKeys": "Chaves Nostr derivadas da semente",
"privacyScreen_derivedKeysDescription": "Suas chaves Nostr são derivadas deterministicamente da sua frase semente.",
"privacyScreen_legacyKeysDescription": "Sua carteira usa chaves Nostr aleatórias. Atualize para derivá-las da sua semente para recuperação mais fácil.",
"privacyScreen_upgradeKeys": "Atualizar",
"privacyScreen_upgradingKeys": "Atualizando chaves Nostr...",
"privateKey": "Private key",
"privateNotePlaceholder": "Sua nota privada",
"profileMissingAddressError": "Endereço faltando",
+5
View File
@@ -321,6 +321,11 @@
"privacyScreen_torDaemon": "Použi Tor sieť",
"privacyScreen_torDaemonDescription": "Umožni spojenie s mintmi na Tor .onion adresách.",
"privacyScreen_torStatus": "Tor status",
"privacyScreen_derivedKeys": "Nostr kľúče odvodené zo seedu",
"privacyScreen_derivedKeysDescription": "Nostr kľúče sú deterministicky odvodené z tvojej seed frázy.",
"privacyScreen_legacyKeysDescription": "Tvoja peňaženka používa náhodné Nostr kľúče. Aktualizuj ich na odvodené z tvojho seedu pre jednoduchšiu obnovu.",
"privacyScreen_upgradeKeys": "Aktualizovať",
"privacyScreen_upgradingKeys": "Aktualizujem Nostr kľúče...",
"privateKey": "Privátny kľúč",
"privateNotePlaceholder": "Súkromná poznámka",
"profileMissingAddressError": "Chýbajúca adresa",
-1
View File
@@ -1,7 +1,6 @@
import {Instance, SnapshotOut, types, flow} from 'mobx-state-tree'
import {JwtTokens, KeyChain, log, MinibitsClient, NostrEvent, NostrKeyPair, NostrUnsignedEvent} from '../services'
import AppError, { Err } from '../utils/AppError'
import { MINIBITS_SERVER_API_HOST } from '@env'
import { finalizeEvent, getEventHash, verifyEvent } from 'nostr-tools/pure'
import { hexToBytes } from '@noble/hashes/utils'
import { decodeJwtExpiry } from '../utils/authUtils'
+25 -23
View File
@@ -48,6 +48,7 @@ export const WalletProfileStoreModel = types
if(!hasKeys) {
log.debug('[publishToRelays] Profile will not be published to relays, wallet keys not yet available.')
return
}
const {pubkey, name, picture, nip05, lud16} = self
@@ -76,7 +77,7 @@ export const WalletProfileStoreModel = types
}
const relaysToPublish: string[] = relaysStore.allUrls
const keys: NostrKeyPair = (yield rootStore.walletStore.getCachedWalletKeys() as Promise<any>).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)
}
})
}))
+19 -9
View File
@@ -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)
}
+11 -7
View File
@@ -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)
}
+130 -14
View File
@@ -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<string>('')
const [isLoggerOn, setIsLoggerOn] = useState<boolean>(
userSettingsStore.isLoggerOn,
)
const [error, setError] = useState<AppError | undefined>()
const [isDerivedKeys, setIsDerivedKeys] = useState<boolean>(false)
const [error, setError] = useState<AppError | undefined>()
// 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) {
<Card
style={[$card, {marginTop: spacing.medium}]}
ContentComponent={
<>
<>
<ListItem
tx="privacyScreen_derivedKeys"
subTx={isDerivedKeys
? "privacyScreen_derivedKeysDescription"
: "privacyScreen_legacyKeysDescription"
}
leftIcon={'faKey'}
leftIconColor={
isDerivedKeys
? colors.palette.success200
: iconColor as string
}
leftIconInverse={true}
RightComponent={
isDerivedKeys ? (
<View style={$rightContainer}>
<Switch
value={true}
disabled={true}
/>
</View>
) : (
<Button
style={{maxHeight: 10, marginTop: spacing.medium}}
preset="secondary"
tx="privacyScreen_upgradeKeys"
onPress={upgradeToNostrDerivedKeys}
/>
)
}
style={$item}
/>
</>
}
/>
<Card
style={[$card, {marginTop: spacing.medium}]}
ContentComponent={
<>
<ListItem
tx="privacyScreen_logger"
subTx="privacyScreen_loggerDescription"
@@ -253,7 +369,7 @@ export const PrivacyScreen = observer(function PrivacyScreen({ route }: Props) {
</>
}
/>
{isLoading && <Loading />}
{isLoading && <Loading statusMessage={statusMessage} />}
</ScrollView>
{/*<BottomModal
isVisible={isTorModalVisible ? true : false}
+31 -21
View File
@@ -266,30 +266,30 @@ export const SeedRecoveryScreen = observer(function SeedRecoveryScreen({ route }
const { updatedAmount: addedAmount } = proofsStore.addOrUpdate(proofStates.UNSPENT, {
mintUrl: recoveredMint.mintUrl,
unit: selectedKeyset.unit as MintUnit,
tId: transaction.id,
tId: transaction!.id,
isPending: false,
isSpent: false,
})
isSpent: false,
})
if (amount !== addedAmount) {
transaction.update({amount: addedAmount})
transaction!.update({amount: addedAmount})
recoveredAmount = addedAmount
}
// Finally, update completed transaction
transactionData.push({
status: TransactionStatus.COMPLETED,
recoveredAmount,
recoveredAmount,
createdAt: new Date(),
})
transaction.update({
transaction!.update({
status: TransactionStatus.COMPLETED,
data: JSON.stringify(transactionData)
})
const balanceAfter = proofsStore.getUnitBalance(selectedKeyset.unit as MintUnit)?.unitBalance
transaction.update({balanceAfter})
transaction!.update({balanceAfter})
}
if(proofStates.PENDING.length > 0) {
@@ -322,22 +322,22 @@ export const SeedRecoveryScreen = observer(function SeedRecoveryScreen({ route }
const { updatedAmount: addedAmount } = proofsStore.addOrUpdate(proofStates.PENDING, {
mintUrl: recoveredMint.mintUrl,
unit: selectedKeyset.unit as MintUnit,
tId: pendingTransaction.id,
tId: pendingTransaction!.id,
isPending: true,
isSpent: false,
isSpent: false,
})
if (pendingAmount !== addedAmount) {
pendingTransaction.update({amount: addedAmount})
pendingTransaction!.update({amount: addedAmount})
}
// Finally, update pending transaction
pendingTransactionData.push({
status: TransactionStatus.PENDING,
status: TransactionStatus.PENDING,
createdAt: new Date(),
})
pendingTransaction.update({
pendingTransaction!.update({
status: TransactionStatus.PENDING,
data: JSON.stringify(pendingTransactionData)
})
@@ -429,12 +429,15 @@ export const SeedRecoveryScreen = observer(function SeedRecoveryScreen({ 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'),
@@ -444,30 +447,37 @@ export const SeedRecoveryScreen = observer(function SeedRecoveryScreen({ 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)
}
+95 -51
View File
@@ -1,6 +1,13 @@
import {observer} from 'mobx-react-lite'
import React, {FC, useEffect, useRef, useState} from 'react'
import {AppState, LayoutAnimation, Platform, ScrollView, TextStyle, View, ViewStyle, useColorScheme} from 'react-native'
import React, {FC, useEffect, useLayoutEffect, useRef, useState} from 'react'
import {AppState, Platform, TextStyle, View, ViewStyle, useColorScheme} from 'react-native'
import Animated, {
useSharedValue,
useAnimatedStyle,
useAnimatedScrollHandler,
interpolate,
Extrapolation,
} from 'react-native-reanimated'
import notifee, { AuthorizationStatus } from '@notifee/react-native'
import messaging from '@react-native-firebase/messaging'
import { HotUpdater, getUpdateSource } from '@hot-updater/react-native'
@@ -8,20 +15,16 @@ import {
HOT_UPDATER_API_KEY,
HOT_UPDATER_URL,
} from '@env'
import {ThemeCode, Themes, colors, spacing, useThemeColor} from '../theme'
import {ListItem, Screen, Text, Card, NwcIcon, Button, BottomModal, InfoModal, Icon} from '../components'
import {useHeader} from '../utils/useHeader'
import {ThemeCode, Themes, colors, spacing, typography, useThemeColor} from '../theme'
import {ListItem, Screen, Text, Card, NwcIcon, Button, BottomModal, InfoModal, Icon, Header} from '../components'
import {useStores} from '../models'
import {translate} from '../i18n'
import { log } from '../services'
import {Env} from '../utils/envtypes'
import { round } from '../utils/number'
import { Currencies, CurrencyCode, availableExchangeCurrencies } from '../services/wallet/currency'
import { NotificationService } from '../services/notificationService'
import { SvgXml } from 'react-native-svg'
import { CurrencySign } from './Wallet/CurrencySign'
import { CommonActions, StaticScreenProps, useNavigation } from '@react-navigation/native'
import { StaticScreenProps, useNavigation } from '@react-navigation/native'
@@ -29,7 +32,22 @@ type Props = StaticScreenProps<undefined>
export const SettingsScreen = observer(function SettingsScreen({ route }: Props) {
const navigation = useNavigation()
useHeader({}) // default header component
const scrollY = useSharedValue(0)
const HEADER_SCROLL_DISTANCE = spacing.screenHeight * 0.07
useLayoutEffect(() => {
navigation.setOptions({
headerShown: true,
header: () => (
<Header
titleTx="settingsScreen_title"
scrollY={scrollY}
scrollDistance={HEADER_SCROLL_DISTANCE}
/>
),
})
}, [])
const appState = useRef(AppState.currentState)
const {
mintsStore,
@@ -49,7 +67,6 @@ export const SettingsScreen = observer(function SettingsScreen({ route }: Props)
const [isThemeModalVisible, setIsThemeModalVisible] = useState<boolean>(false)
const [isNativeUpdateAvailable, setIsNativeUpdateAvailable] = useState<boolean>(false)
const [areNotificationsEnabled, setAreNotificationsEnabled] = useState<boolean>(false)
const [isHeaderVisible, setIsHeaderVisible] = useState(true)
const [info, setInfo] = useState('')
useEffect(() => {
@@ -234,10 +251,6 @@ export const SettingsScreen = observer(function SettingsScreen({ route }: Props)
}
}
}
/* const gotoPreferredUnit = function() {
Alert.alert('Preferred unit is set based on your Wallet screen.')
} */
const getRateColor = function () {
@@ -289,42 +302,71 @@ export const SettingsScreen = observer(function SettingsScreen({ route }: Props)
toggleThemeModal()
}
const collapseHeader = function () {
LayoutAnimation.easeInEaseOut()
setIsHeaderVisible(false)
}
const expandHeader = function () {
LayoutAnimation.easeInEaseOut()
setIsHeaderVisible(true)
}
/* const isCloseToBottom = function ({layoutMeasurement, contentOffset, contentSize}){
return layoutMeasurement.height + contentOffset.y >= contentSize.height - 20
}
const isCloseToTop = function({layoutMeasurement, contentOffset, contentSize}){
return contentOffset.y == 0;
} */
const $itemRight = {color: useThemeColor('textDim')}
const headerBg = useThemeColor('header')
const headerTitle = useThemeColor('headerTitle')
const headerTitle = useThemeColor('headerTitle')
const colorScheme = useColorScheme()
const defaultThemeColor = colorScheme === 'dark' ? Themes[ThemeCode.DARK]?.color : Themes[ThemeCode.LIGHT]?.color
// Collapsible header animation
const HEADER_MAX_HEIGHT = spacing.screenHeight * 0.15
const HEADER_MIN_HEIGHT = spacing.screenHeight * 0.08
const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
scrollY.value = event.contentOffset.y
},
})
const animatedHeaderStyle = useAnimatedStyle(() => {
const height = interpolate(
scrollY.value,
[0, HEADER_SCROLL_DISTANCE],
[HEADER_MAX_HEIGHT, HEADER_MIN_HEIGHT],
Extrapolation.CLAMP
)
return { height }
})
const animatedTitleStyle = useAnimatedStyle(() => {
const scale = interpolate(
scrollY.value,
[0, HEADER_SCROLL_DISTANCE],
[1, 0.75],
Extrapolation.CLAMP
)
const translateY = interpolate(
scrollY.value,
[0, HEADER_SCROLL_DISTANCE],
[0, -spacing.extraLarge * 1.5],
Extrapolation.CLAMP
)
const opacity = interpolate(
scrollY.value,
[0, HEADER_SCROLL_DISTANCE * 0.8],
[1, 0],
Extrapolation.CLAMP
)
return {
transform: [{ scale }, { translateY }],
opacity,
}
})
return (
<Screen contentContainerStyle={$screen} preset='fixed'>
<View style={[isHeaderVisible ? $headerContainer : $headerCollapsed, {backgroundColor: headerBg}]}>
<Text
preset='heading'
tx='settingsScreen_title'
style={{color: headerTitle}}
/>
</View>
<ScrollView
<Animated.View style={[animatedHeaderStyle, $headerContainer, {backgroundColor: headerBg}]}>
<Animated.Text
style={[animatedTitleStyle, $headerTitle, {color: headerTitle}]}
>
{translate('settingsScreen_title')}
</Animated.Text>
</Animated.View>
<Animated.ScrollView
style={$contentContainer}
onScroll={scrollHandler}
scrollEventThrottle={16}
>
<Card
style={$card}
@@ -526,7 +568,7 @@ export const SettingsScreen = observer(function SettingsScreen({ route }: Props)
</>
}
/>
</ScrollView>
</Animated.ScrollView>
<BottomModal
isVisible={isCurrencyModalVisible ? true : false}
style={{alignItems: 'stretch'}}
@@ -595,18 +637,20 @@ const $screen: ViewStyle = {
}
const $headerContainer: TextStyle = {
const $headerContainer: ViewStyle = {
alignItems: 'center',
paddingBottom: spacing.medium,
height: spacing.screenHeight * 0.15,
//justifyContent: 'flex-end',
//paddingBottom: spacing.extraLarge * 2 + spacing.medium,
//overflow: 'visible',
}
const $headerCollapsed: TextStyle = {
alignItems: 'center',
paddingBottom: spacing.medium,
height: spacing.screenHeight * 0.08,
const $headerTitle: TextStyle = {
fontSize: spacing.extraLarge,
//lineHeight: 44,
fontFamily: typography.primary?.medium,
}
const $contentContainer: TextStyle = {
marginTop: -spacing.extraLarge * 2,
padding: spacing.extraSmall,
+79 -27
View File
@@ -1,18 +1,21 @@
import * as _Keychain from 'react-native-keychain'
import AppError, {Err} from '../utils/AppError'
import QuickCrypto from 'react-native-quick-crypto'
import { generateMnemonic as generateNewMnemonic, mnemonicToSeedSync } from "@scure/bip39"
import { wordlist } from "@scure/bip39/wordlists/english"
import * as bip39 from '@scure/bip39'
import { bytesToHex } from '@noble/hashes/utils'
import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'
import { accountFromSeedWords } from 'nostr-tools/nip06'
import {log} from './logService'
import { getRandomUsername } from '../utils/usernames'
// Default account index for NIP-06 derivation (future multi-account support)
const DEFAULT_NOSTR_ACCOUNT_INDEX = 0
export enum KeyChainServiceName {
// NOSTR = 'app.minibits.nostr',
// SEED = 'app.minibits.seed',
// MNEMONIC = 'app.minibits.mnemonic',
BIOMETRIC_AUTH = 'app.minibits.auth',
KEYS = 'app.minibits.keys',
JWT_TOKENS = 'app.minibits.jwt',
@@ -55,28 +58,13 @@ const getSupportedBiometryType = async function () {
}
const generateMnemonic = function () {
try {
log.trace('[generateMnemonic]', 'start')
const mnemonic = generateNewMnemonic(wordlist)
log.trace('[generateMnemonic]', 'New mnemonic created:', {mnemonic})
return mnemonic
} catch (e: any) {
throw new AppError(Err.KEYCHAIN_ERROR, e.message, e)
}
}
const generateNostrKeyPair = function () {
try {
const privateKeyBytes = generateSecretKey() // Uint8Array
const privateKey = bytesToHex(privateKeyBytes)
const publicKey = getPublicKey(privateKeyBytes)
log.trace('New Nostr keypair created', {publicKey, privateKey})
log.trace('New random Nostr keypair created', {publicKey, privateKey})
return {publicKey, privateKey} as NostrKeyPair
} catch (e: any) {
@@ -85,17 +73,80 @@ const generateNostrKeyPair = function () {
}
const generateWalletKeys = function () {
/**
* Derive Nostr keypair from mnemonic using NIP-06
* Uses BIP-32 derivation path: m/44'/1237'/{accountIndex}'/0/0
*
* @param mnemonic BIP-39 mnemonic phrase
* @param accountIndex Account index for derivation (default 0, for future multi-account support)
*/
const deriveNostrKeyPair = function (
mnemonic: string,
accountIndex: number = DEFAULT_NOSTR_ACCOUNT_INDEX
): NostrKeyPair {
try {
const mnemonic = generateMnemonic()
const seedBytesArray = mnemonicToSeedSync(mnemonic)
const { privateKey: privateKeyBytes, publicKey } = accountFromSeedWords(
mnemonic,
'', // No passphrase
accountIndex
)
const privateKey = bytesToHex(privateKeyBytes)
log.trace('[deriveNostrKeyPair] Nostr keypair derived from mnemonic', {publicKey, accountIndex})
return { publicKey, privateKey }
} catch (e: any) {
throw new AppError(Err.KEYCHAIN_ERROR, e.message, e)
}
}
/**
* Check if the wallet's Nostr keys are derived from the mnemonic (NIP-06)
* Returns true if keys match, false if they are legacy random keys
*
* @param walletKeys The wallet keys to check
*/
const areNostrKeysDerived = function (walletKeys: WalletKeys): boolean {
try {
const { NOSTR, SEED } = walletKeys
if (!SEED?.mnemonic || !NOSTR?.publicKey) {
return false
}
// Derive what the keys should be from the mnemonic
const derivedKeys = deriveNostrKeyPair(SEED.mnemonic)
// Compare with stored keys
const isDerived = derivedKeys.publicKey === NOSTR.publicKey
log.trace('[areNostrKeysDerived]', { isDerived, storedPubkey: NOSTR.publicKey, derivedPubkey: derivedKeys.publicKey })
return isDerived
} catch (e: any) {
log.error('[areNostrKeysDerived] Error checking keys', e)
return false
}
}
const generateWalletKeys = function (accountIndex: number = DEFAULT_NOSTR_ACCOUNT_INDEX) {
try {
const mnemonic = bip39.generateMnemonic(wordlist)
log.trace('[generateMnemonic]', 'New mnemonic created:', {mnemonic})
const seedBytesArray = bip39.mnemonicToSeedSync(mnemonic)
const seed = Buffer.from(seedBytesArray).toString('base64')
log.trace('[generateWalletKeys] Seed generated from mnemonic (base64)', {seed})
const seedHash = QuickCrypto
.createHash('sha256')
.update(seedBytesArray)
.digest('hex')
const nostrKeys = generateNostrKeyPair()
// Derive Nostr keys from mnemonic using NIP-06 (BIP-32 derivation path m/44'/1237'/{account}'/0/0)
const nostrKeys = deriveNostrKeyPair(mnemonic, accountIndex)
const walletId = getRandomUsername()
const walletKeys: WalletKeys = {
@@ -114,12 +165,13 @@ const generateWalletKeys = function () {
log.trace('[generateWalletKeys] New keys', {walletKeys})
return walletKeys
} catch (e: any) {
throw new AppError(Err.KEYCHAIN_ERROR, e.message, e)
}
}
/**
* Save wallet keys in KeyChain/KeyStore
*
@@ -428,9 +480,9 @@ const removeJwtTokens = async function (): Promise<boolean> {
export const KeyChain = {
getSupportedBiometryType,
generateMnemonic,
generateNostrKeyPair,
deriveNostrKeyPair,
areNostrKeysDerived,
generateWalletKeys,
saveWalletKeys,
getWalletKeys,
+23 -17
View File
@@ -196,39 +196,45 @@ const updateDeviceToken = async function (deviceToken: string) {
return walletProfile
}
// recover profile address, avatar and seed from former profile (if exists, otherwise only seed is linked) to the current one.
const recoverProfile = async function (walletId: string, seedHash: string) {
// recover profile address, avatar and seedHash from former profile (if exists, otherwise only seedHash is linked) to the current one.
// newPubkey is the NIP-06 derived pubkey from the recovered mnemonic - server will rotate profile to this pubkey
const recoverProfile = async function (walletId: string, seedHash: string, newPubkey?: string) {
const url = MINIBITS_SERVER_API_HOST + '/profile'
const method = 'PUT'
const body = {
const method = 'PUT'
const body: { walletId: string, seedHash: string, newPubkey?: string } = {
walletId,
seedHash
}
seedHash
}
if (newPubkey) {
body.newPubkey = newPubkey
}
const walletProfile = await fetchApi(url + `/recover`, {
method,
method,
body,
jwtAuthRequired: true
}) as WalletProfileRecord
log.info('[recoverProfile]', `Recovered wallet profile `, {walletAddress: walletProfile.nip05})
log.info('[recoverProfile]', `Recovered wallet profile `, {walletAddress: walletProfile.nip05, pubkeyRotated: !!newPubkey})
return walletProfile
}
// recover address only to the current profile, keep current avatar and seed
const recoverAddress = async function (walletId: string, seedHash: string) {
// recover address only from the profile identified by seedHash to the current profile
// keep current avatar, seed and nostr keys.
const recoverAddress = async function (walletId: string, seedHash: string) {
const url = MINIBITS_SERVER_API_HOST + '/profile'
const method = 'PUT'
const body = {
const method = 'PUT'
const body: { walletId: string, seedHash: string } = {
walletId,
seedHash
}
seedHash
}
const walletProfile = await fetchApi(url + `/recoverAddress`, {
method,
method,
body,
jwtAuthRequired: true
}) as WalletProfileRecord