v0.1.8-beta - push notifications, claim ecash in batch, Wallet address recovery tool when moving to new device.

This commit is contained in:
minibits-cash
2024-05-29 12:20:31 +02:00
parent 76402c805a
commit b14f7057df
27 changed files with 779 additions and 240 deletions
+1
View File
@@ -91,6 +91,7 @@ jobs:
echo NATIVE_VERSION_ANDROID='${{ env.ANDROID_VERSION_NAME }}' >> .env
echo JS_BUNDLE_VERSION='${{ env.CURRENT_JS_VERSION }}' >> .env
echo MINIBITS_SERVER_API_KEY='${{ secrets.MINIBITS_SERVER_API_KEY }}' >> .env
echo MINIBIT_SERVER_NOSTR_PUBKEY='${{ secrets.MINIBIT_SERVER_NOSTR_PUBKEY }}' >> .env
echo MINIBITS_SERVER_API_HOST='https://api.minibits.cash' >> .env
echo MINIBITS_NIP05_DOMAIN='@minibits.cash' >> .env
echo MINIBITS_RELAY_URL='wss://relay.minibits.cash' >> .env
+1
View File
@@ -91,6 +91,7 @@ jobs:
echo NATIVE_VERSION_ANDROID='${{ env.ANDROID_VERSION_NAME }}' >> .env
echo JS_BUNDLE_VERSION='${{ env.CURRENT_JS_VERSION }}' >> .env
echo MINIBITS_SERVER_API_KEY='${{ secrets.MINIBITS_SERVER_API_KEY }}' >> .env
echo MINIBIT_SERVER_NOSTR_PUBKEY='${{ secrets.MINIBIT_SERVER_NOSTR_PUBKEY }}' >> .env
echo MINIBITS_SERVER_API_HOST='https://api.minibits.cash' >> .env
echo MINIBITS_NIP05_DOMAIN='@minibits.cash' >> .env
echo MINIBITS_RELAY_URL='wss://relay.minibits.cash/test' >> .env
+3 -2
View File
@@ -1,6 +1,7 @@
apply plugin: "com.android.application"
apply plugin: "com.facebook.react"
apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "com.google.gms.google-services"
// apply plugin: "kotlin-android" // minibits:react-native-camera-kit
// apply plugin: "kotlin-android-extensions"
@@ -102,8 +103,8 @@ android {
applicationId "com.minibits_wallet"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 52001
versionName "0.1.7"
versionCode 55001
versionName "0.1.8"
}
splits {
+1
View File
@@ -20,6 +20,7 @@ buildscript {
classpath("com.android.tools.build:gradle")
classpath("com.facebook.react:react-native-gradle-plugin")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin")
classpath("com.google.gms:google-services:4.4.1")
// classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") // minibits_wallet:react-native-camera-kit
}
}
+7
View File
@@ -3,12 +3,19 @@ import Crypto from 'react-native-quick-crypto' // needed for secp256k1, conf in
import 'react-native-url-polyfill/auto' // URL.host etc
import 'text-encoding-polyfill' // needed in cashu-ts
import {AppRegistry} from 'react-native'
import messaging from '@react-native-firebase/messaging';
import App from './src/App'
import {name as appName} from './app.json'
import { NotificationService } from './src/services/notificationService';
function BootstrapApp() {
return <App appName={appName} />
}
// Setup notification listeners and handlers
messaging().onMessage(NotificationService.onReceiveRemoteNotification)
messaging().setBackgroundMessageHandler(NotificationService.onReceiveRemoteNotification)
AppRegistry.registerComponent(appName, () => BootstrapApp)
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "minibits_wallet",
"version": "0.1.7-beta.17",
"version": "0.1.8-beta.15",
"private": true,
"scripts": {
"android:clean": "cd android && ./gradlew clean",
@@ -26,6 +26,8 @@
"@notifee/react-native": "^7.8.2",
"@react-native-clipboard/clipboard": "^1.13.2",
"@react-native-community/netinfo": "^11.3.1",
"@react-native-firebase/app": "^20.0.0",
"@react-native-firebase/messaging": "^20.0.0",
"@react-navigation/bottom-tabs": "^6.5.20",
"@react-navigation/native": "^6.1.17",
"@react-navigation/native-stack": "^6.9.26",
+21 -5
View File
@@ -1,4 +1,4 @@
import React from 'react'
import React, { useEffect } from 'react'
import * as Sentry from '@sentry/react-native'
import {
APP_ENV,
@@ -9,6 +9,7 @@ import {
CODEPUSH_PRODUCTION_DEPLOYMENT_KEY,
} from '@env'
import codePush from 'react-native-code-push'
import messaging from '@react-native-firebase/messaging'
import FlashMessage from "react-native-flash-message"
import {
initialWindowMetrics,
@@ -20,12 +21,13 @@ import {
} from '@gocodingnow/rn-size-matters'
import {AppNavigator} from './navigation'
import {useInitialRootStore, useStores} from './models'
import {Database} from './services'
import {Database, MinibitsClient} from './services'
import {ErrorBoundary} from './screens/ErrorScreen/ErrorBoundary'
import Config from './config'
import {log} from './services'
import {Env} from './utils/envtypes'
import AppError from './utils/AppError'
// RN 0.73 screen rendering issue
//import { enableFreeze, enableScreens } from 'react-native-screens';
// enableScreens(false)
@@ -55,10 +57,10 @@ interface AppProps {
}
function App(props: AppProps) {
const {userSettingsStore, relaysStore} = useStores()
const {rehydrated} = useInitialRootStore(() => {
const {userSettingsStore, relaysStore, walletProfileStore} = useStores()
const {rehydrated} = useInitialRootStore(async() => {
// This runs after the root store has been initialized and rehydrated from storage.
// Creates and opens a sqlite database that stores transactions history and user settings.
// It triggers db migrations if database version has changed.
@@ -73,8 +75,22 @@ function App(props: AppProps) {
for (const relay of relaysStore.allRelays) {
relay.setStatus(WebSocket.CLOSED)
}
// FCM push notifications - set or refresh token on app start
await messaging().registerDeviceForRemoteMessages()
const deviceToken = await messaging().getToken()
log.trace('[useInitialRootStore]', {deviceToken})
// Save new or refreshed token to local and server profile
if (deviceToken !== walletProfileStore.device) {
walletProfileStore.setDevice(deviceToken)
}
})
if (!rehydrated) {
return null
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { StyleSheet, View, ActivityIndicator, ViewProps, TextStyle } from 'react
import { useThemeColor } from '../theme'
import { Text } from './Text'
export function Loading(props: ViewProps & {statusMessage?: string, textStyle: TextStyle}) {
export function Loading(props: ViewProps & {statusMessage?: string, textStyle?: TextStyle}) {
return (
<View style={[{
flex: 1,
+3 -3
View File
@@ -265,9 +265,9 @@ const en = {
backupScreen: {
localBackup: "Local backup",
localBackupDescription: "Local backup stores a copy of all your ecash in the local database. Ecash is never deleted unless you switch off the backup. Please note that this backup is not encrypted.",
remoteBackup: "Off-device backup",
remoteBackupDescription: "Seed phrase allows you to recover your ecash balance in case of device loss.",
recoveryTool: "Recovery tool",
remoteBackup: "Seed backup",
remoteBackupDescription: "Mnemonic phrase encodes your seed and allows you to recover your ecash balance in case of device loss.",
recoveryTool: "Local recovery tool",
recoveryToolDescription: "Show ecash backed up in the local database and attempt to recover unspent ones into the wallet in case the wallet storage gets corrupted or a transaction fails due to an unexpected error.",
removeSpentCoins: "Remove spent ecash",
removeSpentCoinsDescription: "In case of a SEND or TRANSFER transaction error, spent ecash may remain in your wallet. This blocks further transactions with the mint, making the wallet unusable. This tool removes spent ecash from the wallet."
+6 -1
View File
@@ -256,6 +256,10 @@ export const WalletProfileStoreModel = types
log.info('[recover]', 'Wallet profile recovered in WalletProfileStore', {self, publishedEvent})
return self
}),
setDevice: flow(function* setDevice(device: string) {
self.device = device
yield MinibitsClient.updateDeviceToken(self.pubkey, {deviceToken: device})
}),
setNip05(nip05: string) { // used in migration to v3 model
self.nip05 = nip05
},
@@ -264,7 +268,8 @@ export const WalletProfileStoreModel = types
},
setSeedHash(seedHash: string) { // used in migration to v8 model
self.seedHash = seedHash
}
},
/* setPicture(picture: string) {
self.picture = picture
}*/
+8 -41
View File
@@ -22,7 +22,7 @@ import {Database} from '../../services'
import { log } from '../../services/logService'
import { rootStoreModelVersion } from '../RootStore'
import AppError, { Err } from '../../utils/AppError'
import { MINIBITS_NIP05_DOMAIN } from '@env'
import { MINIBITS_NIP05_DOMAIN, MINIBITS_RELAY_URL } from '@env'
import { LogLevel } from '../../services/log/logTypes'
import { MintStatus } from '../Mint'
@@ -133,41 +133,6 @@ async function _runMigrations(rootStore: RootStore) {
let currentVersion = rootStore.version
try {
if(currentVersion < 3) {
log.trace(`Starting rootStore migrations from version v${currentVersion} -> v3`)
if(walletProfileStore.pubkey) {
walletProfileStore.setNip05(walletProfileStore.name+MINIBITS_NIP05_DOMAIN)
log.info(`Completed rootStore migrations to the version v${rootStoreModelVersion}`)
rootStore.setVersion(rootStoreModelVersion)
}
}
if(currentVersion < 4) {
log.trace(`Starting rootStore migrations from version v${currentVersion} -> v4`)
if(walletProfileStore.pubkey) {
walletProfileStore.setWalletId(userSettingsStore.walletId as string)
// publish profile to relays
await walletProfileStore.publishToRelays()
log.info(`Completed rootStore migrations to the version v${rootStoreModelVersion}`)
rootStore.setVersion(rootStoreModelVersion)
}
}
if(currentVersion < 5) {
log.trace(`Starting rootStore migrations from version v${currentVersion} -> v5`)
if(contactsStore.publicRelay) {
relaysStore.addRelay({
url: contactsStore.publicRelay,
status: WebSocket.CLOSED
})
}
log.info(`Completed rootStore migrations to the version v${rootStoreModelVersion}`)
rootStore.setVersion(rootStoreModelVersion)
}
if(currentVersion < 6) {
log.trace(`Starting rootStore migrations from version v${currentVersion} -> v6`)
@@ -288,14 +253,16 @@ async function _runMigrations(rootStore: RootStore) {
if(currentVersion < 14) {
log.trace(`Starting rootStore migrations from version v${currentVersion} -> v14`)
try {
try {
await walletProfileStore.migrateToNewRelay()
log.info(`Completed rootStore migrations to the version v${rootStoreModelVersion}`)
rootStore.setVersion(rootStoreModelVersion)
} catch (e: any) {
log.warn('[setupRootStore]', e.message)
log.warn('[setupRootStore] Migration error', {message: e.name})
}
log.info(`Completed rootStore migrations to the version v${rootStoreModelVersion}`)
rootStore.setVersion(rootStoreModelVersion)
}
}
} catch (e: any) {
throw new AppError(
+1 -1
View File
@@ -40,7 +40,7 @@ import { colors, useThemeColor } from "../theme"
*/
export type AppStackParamList = {
Welcome: undefined
RemoteRecovery: undefined
RemoteRecovery: {isAddressOnlyRecovery: boolean}
Mints: {}
Tabs: NavigatorScreenParams<TabsParamList>
}
+3
View File
@@ -40,6 +40,7 @@ import {
ReceiveOption,
SendOption,
TokenReceiveScreen,
RemoteRecoveryScreen,
} from "../screens"
import { Contact } from "../models/Contact"
import { colors, useThemeColor, spacing, typography } from "../theme"
@@ -288,6 +289,7 @@ export type SettingsStackParamList = {
LocalRecovery: undefined
Developer: undefined
Relays: undefined
RemoteRecovery: {isAddressOnlyRecovery: boolean}
WalletNavigator: {screen: string}
ContactsNavigator: {screen: string}
}
@@ -319,6 +321,7 @@ const SettingsNavigator = function SettingsNavigator() {
<SettingsStack.Screen name="LocalRecovery" component={LocalRecoveryScreen} />
<SettingsStack.Screen name="Developer" component={DeveloperScreen} />
<SettingsStack.Screen name="Relays" component={RelaysScreen} />
<SettingsStack.Screen name="RemoteRecovery" component={RemoteRecoveryScreen} />
</SettingsStack.Navigator>
)
}
+18 -4
View File
@@ -121,7 +121,11 @@ export const BackupScreen: FC<SettingsStackScreenProps<'Backup'>> = observer(fun
const gotoRemoteBackup = function () {
navigation.navigate('RemoteBackup')
}
}
const gotoRemoteRecovery = function () {
navigation.navigate('RemoteRecovery', {isAddressOnlyRecovery: true})
}
const checkSpent = async function () {
setIsLoading(true)
@@ -157,7 +161,7 @@ export const BackupScreen: FC<SettingsStackScreenProps<'Backup'>> = observer(fun
style={$card}
HeadingComponent={
<>
<ListItem
<ListItem
tx="backupScreen.remoteBackup"
subTx="backupScreen.remoteBackupDescription"
leftIcon='faUpRightFromSquare'
@@ -165,7 +169,17 @@ export const BackupScreen: FC<SettingsStackScreenProps<'Backup'>> = observer(fun
leftIconInverse={true}
style={$item}
onPress={gotoRemoteBackup}
/>
bottomSeparator={true}
/>
<ListItem
text="Wallet address recovery"
subText="Use your mnemonic phrase from another device to get your original wallet address on this device. This will not recover balances."
leftIcon='faCircleUser'
leftIconColor={colors.palette.iconGreyBlue400}
leftIconInverse={true}
style={$item}
onPress={gotoRemoteRecovery}
/>
</>
}
/>
@@ -238,7 +252,7 @@ export const BackupScreen: FC<SettingsStackScreenProps<'Backup'>> = observer(fun
<>
<ListItem
text="Increase recovery indexes"
subText={`After migration from old wallet to the wallet that can be recovered with seed phrase, you may in rare cases encounter 'duplicate outputs' error when trying to send. This resets your recovery indexes to higher value in order to resolve the issue.`}
subText={`After incomplete recovery from seed or other rare issue, you may encounter 'duplicate outputs' error when trying to send. This resets your recovery indexes to higher value in order to resolve the issue.`}
leftIcon='faArrowUp'
leftIconColor={colors.palette.success300}
leftIconInverse={true}
+2 -2
View File
@@ -100,7 +100,7 @@ export const RemoteBackupScreen: FC<SettingsStackScreenProps<'RemoteBackup'>> =
return (
<Screen style={$screen}>
<View style={[$headerContainer, {backgroundColor: headerBg}]}>
<Text preset="heading" text="Off-device backup" style={{color: 'white'}} />
<Text preset="heading" text="Seed backup" style={{color: 'white'}} />
</View>
<View style={$contentContainer}>
<Card
@@ -108,7 +108,7 @@ export const RemoteBackupScreen: FC<SettingsStackScreenProps<'RemoteBackup'>> =
ContentComponent={
<ListItem
text='Your mnemonic phrase'
subText='This 12 word sequence allows you to recover your ecash balance in case of device loss. Keep them in safe place.'
subText='This 12 word sequence represents your seed and allows you to recover your ecash balance in case of device loss. Keep them in safe place.'
leftIcon='faInfoCircle'
leftIconColor={colors.palette.iconYellow300}
leftIconInverse={true}
+212 -102
View File
@@ -2,6 +2,7 @@ import {observer} from 'mobx-react-lite'
import React, {FC, useEffect, useRef, useState} from 'react'
import {FlatList, LayoutAnimation, Platform, Pressable, Switch, TextInput, TextStyle, UIManager, View, ViewStyle} from 'react-native'
import {validateMnemonic} from '@scure/bip39'
import QuickCrypto from 'react-native-quick-crypto'
import { wordlist } from '@scure/bip39/wordlists/english'
import {colors, spacing, useThemeColor} from '../theme'
import {AppStackScreenProps} from '../navigation' // @demo remove-current-line
@@ -42,6 +43,7 @@ import { WalletUtils } from '../services/wallet/utils'
import { WalletScreen } from './WalletScreen'
import { MintUnit, formatCurrency, getCurrency } from '../services/wallet/currency'
import { isObj } from '@cashu/cashu-ts/src/utils'
import { WalletProfileRecord } from '../models/WalletProfileStore'
if (Platform.OS === 'android' &&
UIManager.setLayoutAnimationEnabledExperimental) {
@@ -64,10 +66,12 @@ export const RemoteRecoveryScreen: FC<AppStackScreenProps<'RemoteRecovery'>> = o
const indexInputRef = useRef<TextInput>(null)
const [info, setInfo] = useState('')
const [isAddressOnlyRecovery, setIsAddressOnlyRecovery] = useState(route.params.isAddressOnlyRecovery ? true : false)
const [mnemonic, setMnemonic] = useState<string>('')
const [mnemonicExists, setMnemonicExists] = useState(false)
const [isValidMnemonic, setIsValidMnemonic] = useState(false)
const [seed, setSeed] = useState<Uint8Array>()
const [profileToRecover, setProfileToRecover] = useState<WalletProfileRecord | undefined>(undefined)
const [selectedMintUrl, setSelectedMintUrl] = useState<string | undefined>()
const [selectedKeyset, setSelectedKeyset] = useState<MintKeyset | undefined>()
const [selectedMintKeysets, setSelectedMintKeysets] = useState<MintKeyset[]>([])
@@ -166,7 +170,7 @@ export const RemoteRecoveryScreen: FC<AppStackScreenProps<'RemoteRecovery'>> = o
const onAddMints = function (): void {
return navigation.navigate('Mints')
return navigation.navigate('Mints', {})
}
@@ -473,6 +477,75 @@ export const RemoteRecoveryScreen: FC<AppStackScreenProps<'RemoteRecovery'>> = o
}
const onFindWalletAddress = async () => {
try {
if(!seed || !mnemonic) {
throw new AppError(Err.VALIDATION_ERROR, 'Missing mnemonic or seed.')
}
const seedHash = QuickCrypto.createHash('sha256')
.update(seed)
.digest('hex')
const currentSeedHash = await KeyChain.loadSeedHash()
if(currentSeedHash === seedHash) {
throw new AppError(Err.VALIDATION_ERROR, 'Can not use seed from this device.')
}
const profile = await MinibitsClient.getWalletProfileBySeedHash(seedHash as string)
// Skip external profiles beacause we do not control keys
if(profile) {
log.info('[onCheckWalletAddress] profileToRecover', {profile})
if(profile.nip05.includes(MINIBITS_NIP05_DOMAIN)) {
setProfileToRecover(profile)
} else {
setInfo(`You used wallet address ${profile.nip05} with your own keys, import it again.`)
await delay(5000)
}
} else {
setInfo('Could not find wallet address linked to your seed on the server.')
}
} catch (e: any) {
handleError(e)
}
}
const onCompleteAddress = async () => {
try {
if(!seed || !mnemonic || !profileToRecover) {
throw new AppError(Err.VALIDATION_ERROR, 'Missing mnemonic or seed or profile to recover.')
}
setStatusMessage('Recovering wallet address...')
setIsLoading(true)
await KeyChain.saveMnemonic(mnemonic)
await KeyChain.saveSeed(seed as Uint8Array)
const seedHash = await KeyChain.loadSeedHash()
const {publicKey: newPublicKey} = await NostrClient.getOrCreateKeyPair()
// Updates pubkey and imports wallet profile
await walletProfileStore.recover(seedHash as string, newPublicKey)
// Align walletId in userSettings with recovered profile
userSettingsStore.setWalletId(walletProfileStore.walletId)
await delay(1000)
setStatusMessage(`Recovery completed`)
await delay(2000)
userSettingsStore.setIsOnboarded(true)
setStatusMessage('')
setIsLoading(false)
navigation.navigate('Tabs')
} catch (e: any) {
handleError(e)
}
}
const onComplete = async () => {
try {
@@ -539,7 +612,7 @@ export const RemoteRecoveryScreen: FC<AppStackScreenProps<'RemoteRecovery'>> = o
</View>
<View style={$contentContainer}>
{mnemonicExists ? (
{mnemonicExists && !isAddressOnlyRecovery ? (
<Card
style={$card}
ContentComponent={
@@ -565,7 +638,7 @@ export const RemoteRecoveryScreen: FC<AppStackScreenProps<'RemoteRecovery'>> = o
) : (
<>
{isValidMnemonic ? (
<>
<>
<Card
style={$card}
ContentComponent={
@@ -577,121 +650,158 @@ export const RemoteRecoveryScreen: FC<AppStackScreenProps<'RemoteRecovery'>> = o
/>
}
/>
<Card
style={$card}
HeadingComponent={
<>
<ListItem
text='Recovery from mints'
subText='Identify mints to recover your ecash from and add them to the list.'
LeftComponent={<View style={[$numIcon, {backgroundColor: numIconColor}]}><Text text='2'/></View>}
RightComponent={mintsStore.mintCount > 0 ? (
<View style={$rightContainer}>
{isAddressOnlyRecovery ? (
<>
{profileToRecover ? (
<>
<Card
style={$card}
ContentComponent={
<ListItem
text={profileToRecover.nip05}
subText={'This is the wallet address linked to your seed. If you press Complete, your wallet profile will reset to above seed and address.'}
LeftComponent={<View style={[$numIcon, {backgroundColor: numIconColor}]}><Text text='2'/></View>}
style={$item}
/>
}
/>
<View style={$buttonContainer}>
<Button
onPress={onAddMints}
text='Mints'
preset='secondary'
/>
onPress={onCompleteAddress}
text={'Complete recovery'}
style={{marginRight: spacing.small}}
/>
</View>
) : (undefined)
}
style={$item}
/>
{mintsStore.mintCount === 0 && (
</>
) : (
<View style={$buttonContainer}>
<Button
onPress={onAddMints}
text='Add mints'
/>
onPress={onFindWalletAddress}
text={'Find wallet address'}
style={{marginRight: spacing.small}}
/>
</View>
)}
</>
}
ContentComponent={
<>
{mintsStore.mints.map((mint: Mint, index: number) => (
<MintListItem
key={mint.mintUrl}
mint={mint}
mintBalance={proofsStore.getMintBalance(mint.mintUrl)}
onMintSelect={() => onMintSelect(mint)}
isSelectable={true}
isSelected={selectedMintUrl === mint.mintUrl}
separator={index === 0 ? 'both' : 'bottom'}
/>
))}
</>
}
FooterComponent={
<>
{mintsStore.mintCount > 0 && selectedMintUrl && (
<>
<View style={$buttonContainer}>
{(startIndex > 0 || totalRecoveredAmount > 0) && (
</>
) : (
<>
<Card
style={$card}
HeadingComponent={
<>
<ListItem
text='Recovery from mints'
subText='Identify mints to recover your ecash from and add them to the list.'
LeftComponent={<View style={[$numIcon, {backgroundColor: numIconColor}]}><Text text='2'/></View>}
RightComponent={mintsStore.mintCount > 0 ? (
<View style={$rightContainer}>
<Button
onPress={onAddMints}
text='Mints'
preset='secondary'
/>
</View>
) : (undefined)
}
style={$item}
/>
{mintsStore.mintCount === 0 && (
<View style={$buttonContainer}>
<Button
onPress={onComplete}
text={'Complete'}
style={{marginRight: spacing.small}}
/>
)}
<Button
onPress={startRecovery}
text={startIndex === 0 ? 'Start recovery' : 'Next interval'}
preset={(startIndex === 0 || totalRecoveredAmount > 0) ? 'default' : 'secondary'}
disabled={selectedMintUrl ? false : true}
/>
</View>
onPress={onAddMints}
text='Add mints'
/>
</View>
)}
</>
}
ContentComponent={
<>
{mintsStore.mints.map((mint: Mint, index: number) => (
<MintListItem
key={mint.mintUrl}
mint={mint}
mintBalance={proofsStore.getMintBalance(mint.mintUrl)}
onMintSelect={() => onMintSelect(mint)}
isSelectable={true}
isSelected={selectedMintUrl === mint.mintUrl}
separator={index === 0 ? 'both' : 'bottom'}
/>
))}
</>
}
FooterComponent={
<>
{mintsStore.mintCount > 0 && selectedMintUrl && (
<>
<View style={$buttonContainer}>
{(startIndex > 0 || totalRecoveredAmount > 0) && (
<Button
onPress={onComplete}
text={'Complete'}
style={{marginRight: spacing.small}}
/>
)}
<Button
onPress={startRecovery}
text={startIndex === 0 ? 'Start recovery' : 'Next interval'}
preset={(startIndex === 0 || totalRecoveredAmount > 0) ? 'default' : 'secondary'}
disabled={selectedMintUrl ? false : true}
/>
</View>
<View style={$buttonContainer}>
<Text
text={`Recovery interval ${startIndex} - ${endIndex} · `}
size='xxs'
style={{color: textHint, alignSelf: 'center', marginTop: spacing.small}}
/>
<Pressable onPress={toggleIndexModal}>
<Text
text={`Set manually`}
size='xxs'
style={{color: textHint, alignSelf: 'center', marginTop: spacing.small}}
/>
</Pressable>
</View>
<View style={[$buttonContainer,{marginTop: 0}]}>
<Text
text={`Keyset ID ${selectedKeyset?.id} (${selectedKeyset?.unit})`}
size='xxs'
style={{color: textHint, alignSelf: 'center', marginTop: spacing.extraSmall}}
/>
{selectedMintKeysets.length > 1 && (
<Pressable onPress={toggleKeysetModal}>
<View style={$buttonContainer}>
<Text
text={` · Select another`}
text={`Recovery interval ${startIndex} - ${endIndex} · `}
size='xxs'
style={{color: textHint, alignSelf: 'center', marginTop: spacing.small}}
/>
<Pressable onPress={toggleIndexModal}>
<Text
text={`Set manually`}
size='xxs'
style={{color: textHint, alignSelf: 'center', marginTop: spacing.small}}
/>
</Pressable>
</View>
<View style={[$buttonContainer,{marginTop: 0}]}>
<Text
text={`Keyset ID ${selectedKeyset?.id} (${selectedKeyset?.unit})`}
size='xxs'
style={{color: textHint, alignSelf: 'center', marginTop: spacing.extraSmall}}
/>
</Pressable>
)}
</View>
</>
)}
{mintsStore.mintCount > 0 && !selectedMintUrl && (
<Text
text={`Select mint to recover from`}
size='xxs'
style={{color: textHint, alignSelf: 'center', margin: spacing.large}}
/>
)}
</>
}
/>
</>
/>
{selectedMintKeysets.length > 1 && (
<Pressable onPress={toggleKeysetModal}>
<Text
text={` · Select another`}
size='xxs'
style={{color: textHint, alignSelf: 'center', marginTop: spacing.extraSmall}}
/>
</Pressable>
)}
</View>
</>
)}
{mintsStore.mintCount > 0 && !selectedMintUrl && (
<Text
text={`Select mint to recover from`}
size='xxs'
style={{color: textHint, alignSelf: 'center', margin: spacing.large}}
/>
)}
</>
}
/>
</>
)}
</>
) : (
<Card
style={$card}
ContentComponent={
<ListItem
text='Insert backup mnemonic phrase'
subText='Paste or rewrite 12 word phrase to recover your ecash balance on this device. Separate words by blank spaces.'
text='Insert mnemonic phrase'
subText={`Paste or rewrite 12 word phrase to recover your ${isAddressOnlyRecovery ? 'wallet address' : 'ecash balance and wallet address'} on this device. Separate words by blank spaces.`}
LeftComponent={<View style={[$numIcon, {backgroundColor: numIconColor}]}><Text text='1'/></View>}
style={$item}
/>
+99 -27
View File
@@ -1,6 +1,7 @@
import {observer} from 'mobx-react-lite'
import React, {FC, useCallback, useEffect, useState} from 'react'
import {Alert, FlatList, TextStyle, View, ViewStyle} from 'react-native'
import React, {FC, useCallback, useEffect, useRef, useState} from 'react'
import {Alert, AppState, FlatList, Switch, TextStyle, View, ViewStyle} from 'react-native'
import notifee from '@notifee/react-native'
import {
APP_ENV,
CODEPUSH_STAGING_DEPLOYMENT_KEY,
@@ -18,6 +19,7 @@ import {Env} from '../utils/envtypes'
import { round } from '../utils/number'
import { getCurrency } from '../services/wallet/currency'
import { getMintColor } from './WalletScreen'
import { NotificationService } from '../services/notificationService'
interface SettingsScreenProps extends SettingsStackScreenProps<'Settings'> {}
@@ -28,12 +30,14 @@ export const SettingsScreen: FC<SettingsScreenProps> = observer(
function SettingsScreen(_props) {
const {navigation} = _props
useHeader({}) // default header component
const appState = useRef(AppState.currentState)
const {mintsStore, relaysStore, userSettingsStore} = useStores()
const [isUpdateAvailable, setIsUpdateAvailable] = useState<boolean>(false)
const [updateDescription, setUpdateDescription] = useState<string>('')
const [updateSize, setUpdateSize] = useState<string>('')
const [isNativeUpdateAvailable, setIsNativeUpdateAvailable] = useState<boolean>(false)
const [areNotificationsEnabled, setAreNotificationsEnabled] = useState<boolean>(false)
useEffect(() => {
const checkForUpdate = async () => {
@@ -54,6 +58,43 @@ export const SettingsScreen: FC<SettingsScreenProps> = observer(
}, [])
useEffect(() => {
const getNotificationPermission = async () => {
try {
const enabled = await NotificationService.areNotificationsEnabled()
setAreNotificationsEnabled(enabled)
} catch (e: any) {
log.info(e.name, e.message)
return false // silent
}
}
getNotificationPermission()
}, [])
useEffect(() => {
const subscription = AppState.addEventListener('change', async(nextAppState) => {
if (
appState.current.match(/inactive|background/) &&
nextAppState === 'active') {
try {
const enabled = await NotificationService.areNotificationsEnabled()
setAreNotificationsEnabled(enabled)
} catch (e: any) {
log.info(e.name, e.message)
return false // silent
}
}
appState.current = nextAppState
})
return () => {
subscription.remove()
}
}, [])
const handleBinaryVersionMismatchCallback = function(update: RemotePackage) {
// silent
// setIsNativeUpdateAvailable(true)
@@ -92,6 +133,10 @@ export const SettingsScreen: FC<SettingsScreenProps> = observer(
})
}
const openNotificationSettings = async function() {
await notifee.openNotificationSettings()
}
const gotoPreferredUnit = function() {
Alert.alert('Preferred unit is set based on your Wallet screen.')
}
@@ -143,10 +188,58 @@ export const SettingsScreen: FC<SettingsScreenProps> = observer(
text={getCurrency(userSettingsStore.preferredUnit).code}
/>
</View>
}
bottomSeparator={true}
}
bottomSeparator={false}
onPress={gotoPreferredUnit}
/>
</>
}
/>
<Card
style={[$card, {marginTop: spacing.large}]}
ContentComponent={
<>
<ListItem
text='Push notifications'
leftIcon='faPaperPlane'
leftIconColor={colors.palette.green400}
leftIconInverse={true}
style={$item}
RightComponent={
<View style={$rightContainer}>
<Text
style={$itemRight}
text={areNotificationsEnabled ? 'Enabled' : 'Disabled'}
/>
</View>
}
bottomSeparator={true}
onPress={openNotificationSettings}
/>
<ListItem
text={'Nostr relays'}
subText={`Connected: ${relaysStore.connectedCount}`}
leftIcon='faCircleNodes'
leftIconColor={colors.palette.iconViolet200}
leftIconInverse={true}
RightComponent={
<View style={$rightContainer}>
<Text
style={$itemRight}
text={`${relaysStore.allRelays.length} relays`}
/>
</View>
}
style={$item}
onPress={gotoRelays}
/>
</>
}
/>
<Card
style={[$card, {marginTop: spacing.large}]}
ContentComponent={
<>
<ListItem
tx='settingsScreen.backupRecovery'
leftIcon='faCloudArrowUp'
@@ -202,28 +295,7 @@ export const SettingsScreen: FC<SettingsScreenProps> = observer(
</>
}
/>
<Card
style={[$card, {marginTop: spacing.large}]}
ContentComponent={
<ListItem
text={'Nostr relays'}
subText={`Connected: ${relaysStore.connectedCount}`}
leftIcon='faCircleNodes'
leftIconColor={colors.palette.iconViolet200}
leftIconInverse={true}
RightComponent={
<View style={$rightContainer}>
<Text
style={$itemRight}
text={`${relaysStore.allRelays.length} relays`}
/>
</View>
}
style={$item}
onPress={gotoRelays}
/>
}
/>
</View>
</Screen>
)
@@ -231,7 +303,7 @@ export const SettingsScreen: FC<SettingsScreenProps> = observer(
)
const $screen: ViewStyle = {
flex: 1,
// flex: 1,
}
const $headerContainer: TextStyle = {
+106 -17
View File
@@ -18,6 +18,7 @@ import {
import codePush, { RemotePackage } from 'react-native-code-push'
import {moderateVerticalScale, verticalScale} from '@gocodingnow/rn-size-matters'
import { SvgXml } from 'react-native-svg'
import {getUnixTime} from 'date-fns'
import PagerView, { PagerViewOnPageScrollEventData } from 'react-native-pager-view'
import { ScalingDot } from 'react-native-animated-pagination-dots'
import {useThemeColor, spacing, colors, typography} from '../theme'
@@ -92,15 +93,19 @@ export const WalletScreen: FC<WalletScreenProps> = observer(
const [currentUnit, setCurrentUnit] = useState<MintUnit>(groupedMints.length > 0 ? groupedMints[0].unit : 'sat')
const [info, setInfo] = useState<string>('')
const [defaultMintUrl, setDefaultMintUrl] = useState<string>(MINIBITS_MINT_URL)
const [lastClaimCheck, setLastClaimCheck] = useState<number>(getUnixTime(new Date()))
const [error, setError] = useState<AppError | undefined>()
const [isLoading, setIsLoading] = useState<boolean>(false)
const [isUpdateAvailable, setIsUpdateAvailable] = useState<boolean>(false)
const [isUpdateModalVisible, setIsUpdateModalVisible] = useState<boolean>(false)
const [isSendModalVisible, setIsSendModalVisible] = useState<boolean>(false)
const [isReceiveModalVisible, setIsReceiveModalVisible] = useState<boolean>(false)
const [updateDescription, setUpdateDescription] = useState<string>('')
const [updateSize, setUpdateSize] = useState<string>('')
const [isNativeUpdateAvailable, setIsNativeUpdateAvailable] = useState<boolean>(false)
// On app start
useEffect(() => {
const checkForUpdate = async () => {
try {
@@ -136,21 +141,22 @@ export const WalletScreen: FC<WalletScreenProps> = observer(
// toggleUpdateModal()
}
// On app start
useEffect(() => {
// get deeplink if any
const getInitialData = async () => {
const getInitialData = async () => {
const url = await Linking.getInitialURL()
if (url) {
handleDeeplink({url})
return // deeplinks have priority over clipboard
return // skip further processing so that it does not slow down or clash deep link
}
if(!isInternetReachable) { return }
// Auto-recover inflight proofs - do only on startup and before checkPendingReceived to prevent conflicts
// TODO add manual option to recovery settings
// check lnaddress claims on app start and set timestamp to trigger focus updates
WalletTask.handleClaim().catch(e => false)
// Auto-recover inflight proofs - do only on startup and before checkPendingReceived to prevent conflicts
WalletTask.handleInFlight().catch(e => false)
// Create websocket subscriptions to receive tokens or payment requests by NOSTR DMs
WalletTask.receiveEventsFromRelays().catch(e => false)
@@ -197,7 +203,7 @@ export const WalletScreen: FC<WalletScreenProps> = observer(
}, initial: false})
}
useFocusEffect(
useCallback(() => {
if(!isInternetReachable) {
@@ -205,7 +211,18 @@ export const WalletScreen: FC<WalletScreenProps> = observer(
}
WalletTask.handleSpentFromPending().catch(e => false)
WalletTask.handlePendingTopups().catch(e => false)
WalletTask.handlePendingTopups().catch(e => false)
// check lnaddress claims max once per minute to decrease server load
const nowInSec = getUnixTime(new Date())
log.trace('[useFocusEffect]', {nowInSec, lastClaimCheck, delay: lastClaimCheck ? nowInSec - lastClaimCheck : undefined})
if(lastClaimCheck && nowInSec - lastClaimCheck > 60) {
WalletTask.handleClaim().catch(e => false)
setLastClaimCheck(nowInSec)
}
}, [])
)
@@ -235,6 +252,17 @@ export const WalletScreen: FC<WalletScreenProps> = observer(
WalletTask.handleSpentFromPending().catch(e => false)
WalletTask.handlePendingTopups().catch(e => false)
// check lnaddress claims max once per minute to decrease server load
const nowInSec = getUnixTime(new Date())
log.trace('[appState change]', {nowInSec, lastClaimCheck, delay: lastClaimCheck ? nowInSec - lastClaimCheck : undefined})
if(lastClaimCheck && nowInSec - lastClaimCheck > 60) {
WalletTask.handleClaim().catch(e => false)
setLastClaimCheck(nowInSec)
}
// calls checkPendingReceived if re-connects
NostrClient.reconnectToRelays().catch(e => false)
}
@@ -253,6 +281,15 @@ export const WalletScreen: FC<WalletScreenProps> = observer(
}
const toggleSendModal = () => {
setIsSendModalVisible(previousState => !previousState)
}
const toggleReceiveModal = () => {
setIsReceiveModalVisible(previousState => !previousState)
}
const addMint = async function ({scannedMintUrl = ''} = {}) {
// necessary
navigation.setParams({scannedMintUrl: undefined})
@@ -285,22 +322,30 @@ export const WalletScreen: FC<WalletScreenProps> = observer(
}
}
const gotoTokenReceive = async function () {
/* const routes = navigation.getState()?.routes
const state = navigation.getState()
log.trace('[gotoTokenReceive]', {routes, state}) */
navigation.navigate('TokenReceive', {unit: currentUnit})
const gotoSend = function () {
toggleSendModal()
navigation.navigate('Send', {unit: currentUnit})
}
const gotoSend = function () {
navigation.navigate('Send', {unit: currentUnit})
const gotoLightningPay = async function () {
toggleSendModal()
navigation.navigate('LightningPay', {unit: currentUnit})
}
const gotoScan = function () {
navigation.navigate('Scan')
}
const gotoTokenReceive = async function () {
toggleReceiveModal()
navigation.navigate('TokenReceive', {unit: currentUnit})
}
const gotoTopup = function () {
toggleReceiveModal()
navigation.navigate('Topup', {unit: currentUnit})
}
const gotoTranDetail = function (id: number) {
// navigation.navigate('TranDetail', {id})
navigation.navigate('TransactionsNavigator', {screen: 'TranDetail', params: {id}, initial: false})
@@ -482,7 +527,7 @@ export const WalletScreen: FC<WalletScreenProps> = observer(
//style={{paddingLeft: spacing.medium}}
/>
)}
onPress={gotoSend}
onPress={toggleSendModal}
style={[{backgroundColor: mainButtonColor, borderWidth: 1, borderColor: screenBg}, $buttonTopup]}
preset='tertiary'
text='Send'
@@ -508,7 +553,7 @@ export const WalletScreen: FC<WalletScreenProps> = observer(
color={mainButtonIcon}
/>
)}
onPress={gotoTokenReceive}
onPress={toggleReceiveModal}
text='Receive'
style={[{backgroundColor: mainButtonColor, borderWidth: 1, borderColor: screenBg}, $buttonPay]}
preset='tertiary'
@@ -538,6 +583,50 @@ export const WalletScreen: FC<WalletScreenProps> = observer(
}
onBackButtonPress={toggleUpdateModal}
onBackdropPress={toggleUpdateModal}
/>
<BottomModal
isVisible={isSendModalVisible ? true : false}
style={{alignItems: 'stretch'}}
ContentComponent={
<>
<ListItem
leftIcon='faMoneyBill1'
text='Send Ecash'
subText='Share Ecash or send it to one of your contacts'
onPress={gotoSend}
/>
<ListItem
leftIcon='faBolt'
text='Pay with Lightning'
subText='Pay invoice or to a Lightning address'
onPress={gotoLightningPay}
/>
</>
}
onBackButtonPress={toggleSendModal}
onBackdropPress={toggleSendModal}
/>
<BottomModal
isVisible={isReceiveModalVisible ? true : false}
style={{alignItems: 'stretch'}}
ContentComponent={
<>
<ListItem
leftIcon='faMoneyBill1'
text='Receive Ecash'
subText='Paste or scan Ecash token'
onPress={gotoTokenReceive}
/>
<ListItem
leftIcon='faBolt'
text='Topup with Lightning'
subText='Create Lightning invoice to topup your balance'
onPress={gotoTopup}
/>
</>
}
onBackButtonPress={toggleReceiveModal}
onBackdropPress={toggleReceiveModal}
/>
</Screen>
+1 -1
View File
@@ -104,7 +104,7 @@ export const WelcomeScreen: FC<AppStackScreenProps<'Welcome'>> =
const gotoRecovery = async function () {
try {
navigation.navigate('RemoteRecovery')
navigation.navigate('RemoteRecovery', {isAddressOnlyRecovery: false})
} catch (e: any) {
handleError(e)
}
+1 -1
View File
@@ -146,7 +146,7 @@ const saveSeed = async function (
}
/**
* Loads mnemonic from the KeyChain/KeyStore
* Loads seed from the KeyChain/KeyStore
*
*/
const loadSeed = async function (): Promise<Uint8Array | undefined> {
+2
View File
@@ -8,6 +8,8 @@ import * as Sentry from '@sentry/react-native'
import {rootStoreInstance} from '../models'
import {LogLevel} from './log/logTypes'
// refresh
const {
userSettingsStore
} = rootStoreInstance
+47 -3
View File
@@ -99,6 +99,27 @@ const updateWalletProfileNip05 = async function (pubkey: string, update: {newPub
}
const updateDeviceToken = async function (pubkey: string, update: {deviceToken: string} ) {
const url = MINIBITS_SERVER_API_HOST + '/profile'
const method = 'PUT'
const { deviceToken } = update
const body = {
deviceToken
}
const walletProfile: WalletProfile = await fetchApi(url + `/deviceToken/${pubkey}`, {
method,
body,
})
log.info('[updateDeviceToken]', `Updated wallet deviceToken`, walletProfile.device)
return walletProfile
}
const recoverProfile = async function (seedHash: string, update: {newPubkey: string}) {
const url = MINIBITS_SERVER_API_HOST + '/profile'
const method = 'PUT'
@@ -213,7 +234,7 @@ const createDonation = async function (amount: number, memo: string, pubkey: str
body
})
log.info(`Created new donation invoice`, invoice, 'createDonation')
log.info(`[createDonation] Created new donation invoice`, {invoice})
return invoice
}
@@ -227,14 +248,35 @@ const checkDonationPaid = async function (paymentHash: string, pubkey: string) {
method,
})
log.info(`Got response`, donationPaid, 'checkDonationPaid')
log.info(`[checkDonationPaid] Got response`, donationPaid)
return donationPaid
}
const createClaim = async function (walletId: string, seedHash: string, pubkey: string,) {
const url = MINIBITS_SERVER_API_HOST + '/claim'
const method = 'POST'
const body = {
walletId,
seedHash,
pubkey
}
const claimedInvoices: Array<{token: string, }> = await fetchApi(url, {
method,
body
})
log.info(`[createClaim] Created new claim and got invoices`, {claimedInvoices})
return claimedInvoices
}
const fetchApi = async (url: string, options: MinibitsRequestOptions, timeout = 10000) => { //ms
log.trace('[fetchApi]', {url})
log.trace('[fetchApi] start', url)
const controller = new AbortController()
const body = options.body ? JSON.stringify(options.body) : undefined
@@ -290,6 +332,7 @@ export const MinibitsClient = {
createWalletProfile,
updateWalletProfile,
updateWalletProfileNip05,
updateDeviceToken,
recoverProfile,
migrateSeedHash,
getRandomPictures,
@@ -298,6 +341,7 @@ export const MinibitsClient = {
getWalletProfileBySeedHash,
createDonation,
checkDonationPaid,
createClaim,
getPublicHeaders,
fetchApi,
}
+1 -2
View File
@@ -80,8 +80,7 @@ const reconnectToRelays = async function () {
// recreate subscriptions if all relays down
if(relaysStore.connectedCount === 0) {
WalletTask.handleSpentFromPending().catch(e => false)
WalletTask.handleSpentFromPending().catch(e => false)
WalletTask.handleSpentFromPending().catch(e => false)
}
const pool = getRelayPool()
+75 -18
View File
@@ -1,19 +1,73 @@
import notifee, { AndroidImportance, AuthorizationStatus } from '@notifee/react-native'
import { colors } from '../theme';
import { log } from './logService';
import {
MINIBIT_SERVER_NOSTR_PUBKEY,
} from '@env'
import { FirebaseMessagingTypes } from '@react-native-firebase/messaging';
import { MintUnit, formatCurrency, getCurrency } from './wallet/currency';
import { NostrClient, NostrProfile } from './nostrService';
import AppError, { Err } from '../utils/AppError';
import { Platform } from 'react-native';
export type RemoteMessageReceiveToLnurl = {
type: 'RemoteMessageReceiveToLnurl',
data: {
amount: number,
unit: 'sat',
comment?: string | null,
zapSenderProfile?: NostrProfile
}
}
// Remote notification receive handler
const onReceiveRemoteNotification = async function(remoteMessage: FirebaseMessagingTypes.RemoteMessage) {
log.debug('[onReceiveRemoteNotification]', {remoteMessage})
try {
const {encrypted} = remoteMessage.data!
if (!encrypted) {
throw new AppError(Err.VALIDATION_ERROR, 'Unknown remote message data received', {data: remoteMessage.data})
}
const serverPubkey = MINIBIT_SERVER_NOSTR_PUBKEY
const decrypted = await NostrClient.decryptNip04(serverPubkey, encrypted as string)
if (decrypted) {
const remoteData: RemoteMessageReceiveToLnurl = JSON.parse(decrypted)
const {amount, unit, comment, zapSenderProfile} = remoteData.data
const currencyCode = getCurrency(unit as MintUnit).code
await createLocalNotification(
`<b>⚡${formatCurrency(amount, currencyCode)} ${currencyCode}</b> incoming!`,
`${zapSenderProfile ? 'Zap' : 'Payment'} from <b>${zapSenderProfile?.nip05 || 'unknown payer'}</b> is ready to be received.${comment ? ' Message from sender: ' + comment : ''}`,
zapSenderProfile?.picture
)
}
} catch (e: any) {
log.error(e.name, e.message)
}
}
// Local notification creation
const createLocalNotification = async function (title: string, body: string, largeIcon?: string) {
log.trace('Start', {title, body, largeIcon}, 'createLocalNotification')
// Request permissions (required for iOS)
await notifee.requestPermission()
if(Platform.OS === 'ios') {
await notifee.requestPermission()
}
// Create a channel (required for Android)
const channelId = await notifee.createChannel({
id: 'important',
name: 'Minibits payment notifications',
vibration: true,
importance: AndroidImportance.HIGH,
id: 'default',
name: 'Minibits notifications',
vibration: true,
importance: AndroidImportance.HIGH,
})
// Display a notification
@@ -24,7 +78,10 @@ const createLocalNotification = async function (title: string, body: string, lar
android: {
channelId,
color: colors.palette.success200,
largeIcon
largeIcon,
pressAction: {
id: 'default',
},
},
})
@@ -35,7 +92,10 @@ const createLocalNotification = async function (title: string, body: string, lar
body,
android: {
channelId,
color: colors.palette.success200,
color: colors.palette.success200,
pressAction: {
id: 'default',
},
},
})
@@ -44,14 +104,12 @@ const createLocalNotification = async function (title: string, body: string, lar
}
const updateLocalNotification = async function (id: string, update: { title: string, body: string}) {
// Request permissions (required for iOS)
// await notifee.requestPermission()
/*const updateLocalNotification = async function (id: string, update: { title: string, body: string}) {
// Create a channel (required for Android)
const channelId = await notifee.createChannel({
id: 'default',
name: 'Default Channel',
name: 'Minibits notifications',
})
const {title, body} = update
@@ -64,11 +122,9 @@ const updateLocalNotification = async function (id: string, update: { title: str
android: {
channelId,
color: colors.palette.success200,
// smallIcon: 'name-of-a-small-icon', // optional, defaults to 'ic_launcher'.
// pressAction is needed if you want the notification to open the app when pressed
/* pressAction: {
pressAction: {
id: 'default',
},*/
},
},
});
}
@@ -76,7 +132,7 @@ const updateLocalNotification = async function (id: string, update: { title: str
const cancelNotification = async function (id: string) {
await notifee.cancelNotification(id)
}
}*/
const areNotificationsEnabled = async function (): Promise<boolean> {
@@ -90,8 +146,9 @@ const areNotificationsEnabled = async function (): Promise<boolean> {
}
export const NotificationService = {
onReceiveRemoteNotification,
createLocalNotification,
updateLocalNotification,
cancelNotification,
// updateLocalNotification,
// cancelNotification,
areNotificationsEnabled
}
+73 -6
View File
@@ -17,12 +17,12 @@ import {MeltQuoteResponse, type Proof as CashuProof} from '@cashu/cashu-ts'
import {Mint} from '../models/Mint'
import {pollerExists, stopPolling} from '../utils/poller'
import EventEmitter from '../utils/eventEmitter'
import { NostrClient, NostrEvent, NostrFilter, NostrProfile } from './nostrService'
import { MINIBITS_NIP05_DOMAIN, MINIBITS_SERVER_API_HOST } from '@env'
import { NostrClient, NostrEvent, NostrProfile } from './nostrService'
import { MINIBITS_NIP05_DOMAIN, MINIBITS_SERVER_API_HOST, MINIBIT_SERVER_NOSTR_PUBKEY } from '@env'
import { PaymentRequest, PaymentRequestStatus, PaymentRequestType } from '../models/PaymentRequest'
import { IncomingDataType, IncomingParser } from './incomingParser'
import { Contact } from '../models/Contact'
import { getDefaultAmountPreference } from '@cashu/cashu-ts/src/utils'
import { getDecodedToken, getDefaultAmountPreference } from '@cashu/cashu-ts/src/utils'
import { SyncQueue } from './syncQueueService'
import { receiveTask, receiveOfflinePrepareTask, receiveOfflineCompleteTask} from './wallet/receiveTask'
import { sendTask } from './wallet/sendTask'
@@ -31,6 +31,7 @@ import { transferTask } from './wallet/transferTask'
import { WalletUtils } from './wallet/utils'
import { NotificationService } from './notificationService'
import { CurrencyCode, MintUnit, formatCurrency, getCurrency } from './wallet/currency'
import { MinibitsClient } from './minibitsService'
type WalletTaskService = {
@@ -42,11 +43,12 @@ type WalletTaskService = {
isPending: boolean
}
) => Promise<void>
handleInFlight: () => Promise<void>
handleInFlight: () => Promise<void>
handlePendingTopups: () => Promise<void>
handlePendingTopup: (params: {
paymentRequest: PaymentRequest
}) => Promise<void>
handleClaim: () => Promise<void>
receiveEventsFromRelays: () => Promise<void>
transfer: (
mintBalanceToTransferFrom: MintBalance,
@@ -971,6 +973,70 @@ const _handlePendingTopupTask = async function (params: {paymentRequest: Payment
}
const handleClaim = async function (): Promise<void> {
log.info('[handleClaim] start')
const {walletId, seedHash, pubkey} = walletProfileStore
if(!walletId || !seedHash || !pubkey) {
log.warn('[handleClaim] Missing or incomplete walletProfile, skipping claim request...')
return
}
const claimedInvoices = await MinibitsClient.createClaim(walletId, seedHash, pubkey)
log.debug(claimedInvoices)
if(claimedInvoices.length === 0) {
log.info('[handleClaim] No claimed invoices returned from server...')
return
}
for(const claimedInvoice of claimedInvoices) {
const now = new Date().getTime()
SyncQueue.addTask(
`_handleClaimTask-${now}`,
async () => await _handleClaimTask({encryptedToken: claimedInvoice.token})
)
}
return
}
const _handleClaimTask = async function (params: {encryptedToken: string}) {
const {encryptedToken} = params
if(!encryptedToken) {
throw new AppError(Err.VALIDATION_ERROR, '[_handleClaimTask] Missing encodedToken to receive.')
}
const encodedToken = await NostrClient.decryptNip04(MINIBIT_SERVER_NOSTR_PUBKEY, encryptedToken)
log.debug('[_handleClaimTask] decrypted token', {encodedToken})
const decoded: Token = CashuUtils.decodeToken(encodedToken)
const amountToReceive = CashuUtils.getTokenAmounts(decoded).totalAmount
const memo = decoded.memo || 'Received to Lightning address'
const result: TransactionTaskResult = await receiveTask(
decoded,
amountToReceive,
memo,
encodedToken,
)
return {
mintUrl: decoded.token[0].mint,
taskFunction: '_handleClaimTask',
message: 'Ecash sent to your lightning address has been received.',
proofsCount: decoded.token[0].proofs.length,
proofsAmount: result.transaction?.amount,
} as WalletTaskResult
}
const _sendTopupNotification = async function (pr: PaymentRequest) {
@@ -1261,6 +1327,7 @@ const _sendReceiveNotification = async function (
try {
const zapSenderProfile: NostrProfile = JSON.parse(maybeZapSenderString)
if(zapSenderProfile) {
sentFromPubkey = zapSenderProfile.pubkey // zap sender pubkey
sentFrom = zapSenderProfile.nip05 || zapSenderProfile.name
@@ -1271,7 +1338,7 @@ const _sendReceiveNotification = async function (
const contactInstance = contactsStore.findByPubkey(sentFromPubkey)
if(contactInstance && sentFromLud16) {
contactInstance.setLud16(sentFromLud16)
}
}
}
} catch (e: any) {
@@ -1310,7 +1377,6 @@ const _extractZapSenderData = function (str: string) {
export const WalletTask: WalletTaskService = {
handleSpentFromPending,
handleSpentFromSpendable,
@@ -1318,6 +1384,7 @@ export const WalletTask: WalletTaskService = {
handleInFlight,
handlePendingTopups,
handlePendingTopup,
handleClaim,
receiveEventsFromRelays,
receive,
receiveOfflinePrepare,
+2 -1
View File
@@ -5,7 +5,8 @@ declare module '@env' {
export const CODEPUSH_STAGING_DEPLOYMENT_KEY: string
export const NATIVE_VERSION_ANDROID: string
export const MINIBITS_SERVER_API_KEY: string
export const MINIBITS_SERVER_API_HOST: string
export const MINIBITS_SERVER_API_HOST: string
export const MINIBIT_SERVER_NOSTR_PUBKEY: string
export const MINIBITS_NIP05_DOMAIN: string
export const MINIBITS_RELAY_URL: string
export const MINIBITS_MINT_URL: string
+81 -1
View File
@@ -1850,6 +1850,19 @@
resolved "https://registry.yarnpkg.com/@react-native-community/netinfo/-/netinfo-11.3.1.tgz#4539371a2f4bd402d932031be82c2449ed63a1a5"
integrity sha512-UBnJxyV0b7i9Moa97Av+HKho1ByzX0DtbJXzUQS5E3xhQs6P2D/Os0iw3ouy7joY1TVd6uIhplPbr7l1SJNaNQ==
"@react-native-firebase/app@^20.0.0":
version "20.0.0"
resolved "https://registry.yarnpkg.com/@react-native-firebase/app/-/app-20.0.0.tgz#0aae0fdaaa0db48f6b248fb410e18687e285f2e1"
integrity sha512-h9Xgmj0Z0ga1ponhDnQ63yqkX288LOW+ZvstDWHDo2rgPu58nbAmdzs73MTmPjYdgm5Elm71puG1NRJX5n7wRw==
dependencies:
opencollective-postinstall "^2.0.3"
superstruct "^0.6.2"
"@react-native-firebase/messaging@^20.0.0":
version "20.0.0"
resolved "https://registry.yarnpkg.com/@react-native-firebase/messaging/-/messaging-20.0.0.tgz#92d25294361ef7e7341b916a903f8fc0abb53abc"
integrity sha512-sBc3SRmUW9+T75YKa374kV4TfAz5EemBc9rXHYlgTI2DQeBhWNQbazAbKEJyyZOJDbs2RgR9f330VWCP7jLTBA==
"@react-native/assets-registry@0.73.1":
version "0.73.1"
resolved "https://registry.yarnpkg.com/@react-native/assets-registry/-/assets-registry-0.73.1.tgz#e2a6b73b16c183a270f338dc69c36039b3946e85"
@@ -3356,6 +3369,16 @@ cliui@^8.0.1:
strip-ansi "^6.0.1"
wrap-ansi "^7.0.0"
clone-deep@^2.0.1:
version "2.0.2"
resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-2.0.2.tgz#00db3a1e173656730d1188c3d6aced6d7ea97713"
integrity sha512-SZegPTKjCgpQH63E+eN6mVEEPdQBOUzjyJm5Pora4lrwWRFS8I0QAxV/KD6vV/i0WuijHZWQC1fMsPEdxfdVCQ==
dependencies:
for-own "^1.0.0"
is-plain-object "^2.0.4"
kind-of "^6.0.0"
shallow-clone "^1.0.0"
clone-deep@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-4.0.1.tgz#c19fd9bdbbf85942b4fd979c84dcf7d5f07c2387"
@@ -4510,6 +4533,23 @@ for-each@^0.3.3:
dependencies:
is-callable "^1.1.3"
for-in@^0.1.3:
version "0.1.8"
resolved "https://registry.yarnpkg.com/for-in/-/for-in-0.1.8.tgz#d8773908e31256109952b1fdb9b3fa867d2775e1"
integrity sha512-F0to7vbBSHP8E3l6dCjxNOLuSFAACIxFy3UehTUlG7svlXi37HHsDkyVcHo0Pq8QwrE+pXvWSVX3ZT1T9wAZ9g==
for-in@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
integrity sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==
for-own@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/for-own/-/for-own-1.0.0.tgz#c63332f415cedc4b04dbfe70cf836494c53cb44b"
integrity sha512-0OABksIGrxKK8K4kynWkQ7y1zounQxP+CWnyclVwj81KW3vlLlGUx57DKGcP/LH216GzqnstnPocF16Nxs0Ycg==
dependencies:
for-in "^1.0.1"
form-data@^2.3.3:
version "2.5.1"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.5.1.tgz#f2cbec57b5e59e23716e128fe44d4e5dd23895f4"
@@ -5092,6 +5132,11 @@ is-docker@^2.0.0:
resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa"
integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==
is-extendable@^0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89"
integrity sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==
is-extglob@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
@@ -5866,7 +5911,12 @@ keyv@^4.5.3:
dependencies:
json-buffer "3.0.1"
kind-of@^6.0.2:
kind-of@^5.0.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-5.1.0.tgz#729c91e2d857b7a419a1f9aa65685c4c33f5845d"
integrity sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==
kind-of@^6.0.0, kind-of@^6.0.1, kind-of@^6.0.2:
version "6.0.3"
resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd"
integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==
@@ -6385,6 +6435,14 @@ minimist@^1.2.6:
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
mixin-object@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/mixin-object/-/mixin-object-2.0.1.tgz#4fb949441dab182540f1fe035ba60e1947a5e57e"
integrity sha512-ALGF1Jt9ouehcaXaHhn6t1yGWRqGaHkPFndtFVHfZXOvkIZ/yoGaSi0AHVTafb3ZBGg4dr/bDwnaEKqCXzchMA==
dependencies:
for-in "^0.1.3"
is-extendable "^0.1.1"
mkdirp@^0.5.1:
version "0.5.6"
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6"
@@ -6659,6 +6717,11 @@ open@^7.0.3, open@^7.4.2:
is-docker "^2.0.0"
is-wsl "^2.1.1"
opencollective-postinstall@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz#7a0fff978f6dbfa4d006238fbac98ed4198c3259"
integrity sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==
optionator@^0.9.3:
version "0.9.3"
resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.3.tgz#007397d44ed1872fdc6ed31360190f81814e2c64"
@@ -7786,6 +7849,15 @@ sha.js@^2.4.0, sha.js@^2.4.8:
inherits "^2.0.1"
safe-buffer "^5.0.1"
shallow-clone@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-1.0.0.tgz#4480cd06e882ef68b2ad88a3ea54832e2c48b571"
integrity sha512-oeXreoKR/SyNJtRJMAKPDSvd28OqEwG4eR/xc856cRGBII7gX9lvAqDxusPm0846z/w/hWYjI1NpKwJ00NHzRA==
dependencies:
is-extendable "^0.1.1"
kind-of "^5.0.0"
mixin-object "^2.0.1"
shallow-clone@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-3.0.1.tgz#8f2981ad92531f55035b01fb230769a40e02efa3"
@@ -8129,6 +8201,14 @@ superagent@^8.0.0:
qs "^6.11.0"
semver "^7.3.8"
superstruct@^0.6.2:
version "0.6.2"
resolved "https://registry.yarnpkg.com/superstruct/-/superstruct-0.6.2.tgz#c5eb034806a17ff98d036674169ef85e4c7f6a1c"
integrity sha512-lvA97MFAJng3rfjcafT/zGTSWm6Tbpk++DP6It4Qg7oNaeM+2tdJMuVgGje21/bIpBEs6iQql1PJH6dKTjl4Ig==
dependencies:
clone-deep "^2.0.1"
kind-of "^6.0.1"
supports-color@^5.3.0:
version "5.5.0"
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"