mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 19:28:26 +00:00
Implement foreground service to handle long running NWC tasks
This commit is contained in:
@@ -96,8 +96,8 @@ android {
|
||||
applicationId "com.minibits_wallet"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 58013
|
||||
versionName "0.1.9"
|
||||
versionCode 58015
|
||||
versionName "0.1.10"
|
||||
}
|
||||
|
||||
splits {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<application
|
||||
android:name=".MainApplication"
|
||||
android:label="@string/app_name"
|
||||
|
||||
@@ -4,17 +4,39 @@ install()
|
||||
import 'react-native-url-polyfill/auto' // URL.host etc
|
||||
import 'text-encoding-polyfill' // cashu-ts
|
||||
import 'message-port-polyfill' // nostr-tools
|
||||
import notifee from '@notifee/react-native'
|
||||
import messaging from '@react-native-firebase/messaging'
|
||||
import {AppRegistry} from 'react-native'
|
||||
import messaging from '@react-native-firebase/messaging';
|
||||
import { rootStoreInstance, setupRootStore } from './src/models'
|
||||
import { NotificationService } from './src/services/notificationService'
|
||||
import { log } from './src/services/logService'
|
||||
import App from './src/App'
|
||||
import {name as appName} from './app.json'
|
||||
import { NotificationService } from './src/services/notificationService';
|
||||
|
||||
|
||||
function BootstrapApp() {
|
||||
return <App appName={appName} />
|
||||
}
|
||||
|
||||
// Process nwc commands as long running headless tasks
|
||||
notifee.registerForegroundService(async (notification) => {
|
||||
return new Promise(async (resolve) => {
|
||||
log.trace('[registerForegroundService] Foreground service running for task:', notification)
|
||||
|
||||
try {
|
||||
const {nwcStore} = rootStoreInstance
|
||||
if(nwcStore.all.length === 0) {
|
||||
await setupRootStore(rootStoreInstance)
|
||||
}
|
||||
|
||||
nwcStore.handleNwcRequestFromNotification(notification.data).catch((e) => {
|
||||
log.error('[registerForegroundService]', e.message)
|
||||
})
|
||||
|
||||
} catch(e) {
|
||||
log.error('[registerForegroundService]', e.message)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Setup notification listeners and handlers
|
||||
messaging().onMessage(NotificationService.onForegroundNotification)
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "minibits_wallet",
|
||||
"version": "0.1.9-beta.39",
|
||||
"version": "0.1.10-beta.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android:clean": "cd android && ./gradlew clean",
|
||||
@@ -29,7 +29,7 @@
|
||||
"@gandlaf21/bc-ur": "^1.1.12",
|
||||
"@gandlaf21/bolt11-decode": "^3.1.1",
|
||||
"@gocodingnow/rn-size-matters": "^0.0.4",
|
||||
"@notifee/react-native": "^9.0.2",
|
||||
"@notifee/react-native": "^9.1.3",
|
||||
"@react-native-clipboard/clipboard": "^1.14.1",
|
||||
"@react-native-community/netinfo": "^11.4.0",
|
||||
"@react-native-firebase/app": "^20.5.0",
|
||||
|
||||
+6
-23
@@ -9,33 +9,30 @@ import {
|
||||
CODEPUSH_PRODUCTION_DEPLOYMENT_KEY,
|
||||
} from '@env'
|
||||
import codePush from 'react-native-code-push'
|
||||
import messaging, { isAutoInitEnabled } from '@react-native-firebase/messaging'
|
||||
import FlashMessage from "react-native-flash-message"
|
||||
import {
|
||||
initialWindowMetrics,
|
||||
import {
|
||||
SafeAreaProvider,
|
||||
} from 'react-native-safe-area-context'
|
||||
import {
|
||||
setSizeMattersBaseHeight,
|
||||
setSizeMattersBaseWidth
|
||||
} from '@gocodingnow/rn-size-matters'
|
||||
import RNExitApp from 'react-native-exit-app'
|
||||
import {AppNavigator} from './navigation'
|
||||
import {useInitialRootStore, useStores} from './models'
|
||||
import {Database, KeyChain} from './services'
|
||||
import { initNotifications } from './services/notificationService'
|
||||
import {ErrorBoundary} from './screens/ErrorScreen/ErrorBoundary'
|
||||
import Config from './config'
|
||||
import {log} from './services'
|
||||
import {Env} from './utils/envtypes'
|
||||
import AppError from './utils/AppError'
|
||||
import { Image, View } from 'react-native'
|
||||
import { Text } from './components'
|
||||
import RNExitApp from 'react-native-exit-app'
|
||||
|
||||
/* Init push notifications */
|
||||
initNotifications()
|
||||
|
||||
// RN 0.73 screen rendering issue
|
||||
//import { enableFreeze, enableScreens } from 'react-native-screens';
|
||||
// enableScreens(false)
|
||||
|
||||
/* Set default size ratio for styling */
|
||||
setSizeMattersBaseWidth(375)
|
||||
setSizeMattersBaseHeight(812)
|
||||
|
||||
@@ -99,20 +96,6 @@ function App(props: AppProps) {
|
||||
|
||||
}
|
||||
|
||||
// FCM push notifications - set or refresh device token on app start
|
||||
await messaging().registerDeviceForRemoteMessages()
|
||||
const deviceToken = await messaging().getToken()
|
||||
|
||||
log.debug('[useInitialRootStore]', {deviceToken})
|
||||
|
||||
// Make sure profile has already been created (i.e. this is not first run)
|
||||
if(walletProfileStore.pubkey && deviceToken) {
|
||||
// if device token changed, update the server
|
||||
if(deviceToken !== walletProfileStore.device) {
|
||||
await walletProfileStore.setDevice(deviceToken)
|
||||
}
|
||||
}
|
||||
|
||||
// Set initial websocket to close as it might have remained open on last app close
|
||||
relaysStore.resetStatuses()
|
||||
log.trace('[useInitialRootStore]', 'App is ready to render')
|
||||
|
||||
+40
-82
@@ -45,7 +45,7 @@ type NwcError = {
|
||||
}
|
||||
}
|
||||
|
||||
type NwcRequest = {
|
||||
export type NwcRequest = {
|
||||
method: string,
|
||||
params: any
|
||||
}
|
||||
@@ -68,7 +68,7 @@ type NwcTransaction = {
|
||||
expires_at: number | null
|
||||
}
|
||||
|
||||
const nwcPngUrl = 'https://1044827509-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F0JQfRPMJ4uO7z9wmnAOK%2Fuploads%2FVO76qdgzHHzWSDsHQXdu%2FGroup%201000001143%20(1).png?alt=media&token=0fdb70b7-bb19-4bed-a752-a0560585c2f4&width=512&dpr=1&quality=100&sign=57c41699&sv=1'
|
||||
export const nwcPngUrl = 'https://1044827509-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F0JQfRPMJ4uO7z9wmnAOK%2Fuploads%2FVO76qdgzHHzWSDsHQXdu%2FGroup%201000001143%20(1).png?alt=media&token=0fdb70b7-bb19-4bed-a752-a0560585c2f4&width=512&dpr=1&quality=100&sign=57c41699&sv=1'
|
||||
|
||||
const getConnectionRelays = function () {
|
||||
const minibitsRelays = NostrClient.getMinibitsRelays()
|
||||
@@ -251,6 +251,8 @@ export const NwcConnectionModel = types.model('NwcConnection', {
|
||||
// notify completed payment
|
||||
const enabled = yield NotificationService.areNotificationsEnabled()
|
||||
if(enabled) {
|
||||
yield NotificationService.stopForegroundService()
|
||||
|
||||
yield NotificationService.createLocalNotification(
|
||||
`<b>${self.name}</b> - Nostr Wallet Connect`,
|
||||
`Paid ${result.transaction.amount} SAT${result.transaction.fee > 0 ? ', fee ' + result.transaction.fee + ' SAT' : ''}. Remaining today's limit is ${self.remainingDailyLimit} SAT`,
|
||||
@@ -307,9 +309,11 @@ export const NwcConnectionModel = types.model('NwcConnection', {
|
||||
// notify created topup
|
||||
const enabled = yield NotificationService.areNotificationsEnabled()
|
||||
if(enabled) {
|
||||
yield NotificationService.stopForegroundService()
|
||||
|
||||
yield NotificationService.createLocalNotification(
|
||||
`<b>${self.name}</b> - Nostr Wallet Connect`,
|
||||
`Invoice for ${result.paymentRequest.amount} SATS has been created.`,
|
||||
`Invoice for ${result.paymentRequest.invoicedAmount} SATS has been created.`,
|
||||
nwcPngUrl
|
||||
)
|
||||
}
|
||||
@@ -514,38 +518,23 @@ export const NwcConnectionModel = types.model('NwcConnection', {
|
||||
error: { code: 'QUOTA_EXCEEDED', message}
|
||||
} as NwcError
|
||||
}
|
||||
|
||||
const meltQuote: MeltQuoteResponse = yield walletStore.createLightningMeltQuote(
|
||||
mintBalance.mintUrl,
|
||||
'sat',
|
||||
encoded,
|
||||
)
|
||||
|
||||
if(isMinibitsMintSelected) {
|
||||
// Process the payment partly on the server side to avoid android background processing failures
|
||||
WalletTask.nwcTransfer(
|
||||
mintBalance,
|
||||
amountToPay,
|
||||
feeReserve,
|
||||
'sat',
|
||||
description || '',
|
||||
invoiceExpiry as Date,
|
||||
encoded,
|
||||
requestEvent
|
||||
)
|
||||
} else {
|
||||
// Full process on device for other mints so that minibits server does not touch foreign ecash
|
||||
const meltQuote: MeltQuoteResponse = yield walletStore.createLightningMeltQuote(
|
||||
mintBalance.mintUrl,
|
||||
'sat',
|
||||
encoded,
|
||||
)
|
||||
|
||||
WalletTask.transfer(
|
||||
mintBalance,
|
||||
amountToPay,
|
||||
'sat',
|
||||
meltQuote,
|
||||
description || '',
|
||||
invoiceExpiry as Date,
|
||||
encoded,
|
||||
requestEvent
|
||||
)
|
||||
}
|
||||
WalletTask.transfer(
|
||||
mintBalance,
|
||||
amountToPay,
|
||||
'sat',
|
||||
meltQuote,
|
||||
description || '',
|
||||
invoiceExpiry as Date,
|
||||
encoded,
|
||||
requestEvent
|
||||
)
|
||||
|
||||
} catch (e: any) {
|
||||
log.error(`[NwcConnection.handlePayInvoice] ${e.message}`)
|
||||
@@ -558,11 +547,15 @@ export const NwcConnectionModel = types.model('NwcConnection', {
|
||||
})
|
||||
}))
|
||||
.actions(self => ({
|
||||
handleRequest: flow(function* handleRequest(requestEvent: NostrEvent) {
|
||||
// decrypt with main wallet privKey. Event.pubkey = connectionPubkey
|
||||
const decryptedContent = yield NostrClient.decryptNip04(requestEvent.pubkey, requestEvent.content)
|
||||
|
||||
const nwcRequest: NwcRequest = JSON.parse(decryptedContent)
|
||||
handleRequest: flow(function* handleRequest(requestEvent: NostrEvent, decryptedNwcRequest?: NwcRequest) {
|
||||
let nwcRequest: NwcRequest
|
||||
if(!decryptedNwcRequest) {
|
||||
const decryptedContent = yield NostrClient.decryptNip04(requestEvent.pubkey, requestEvent.content)
|
||||
nwcRequest = JSON.parse(decryptedContent)
|
||||
} else {
|
||||
nwcRequest = decryptedNwcRequest
|
||||
}
|
||||
|
||||
let nwcResponse: NwcResponse | NwcError
|
||||
|
||||
log.trace('[Nwc.handleRequest] request event', {requestEvent})
|
||||
@@ -581,8 +574,9 @@ export const NwcConnectionModel = types.model('NwcConnection', {
|
||||
case 'make_invoice':
|
||||
nwcResponse = yield self.handleMakeInvoice(nwcRequest, requestEvent) as Promise<NwcError | undefined>
|
||||
|
||||
// no early error, topup initiated, exit and create response in topup result event handler
|
||||
if(!nwcResponse) {
|
||||
// no early error, topup initiated, register topup result event handler and exit
|
||||
if(!nwcResponse) {
|
||||
EventEmitter.on('ev_topupTask_result', self.handleTopupTaskResult)
|
||||
return
|
||||
}
|
||||
break
|
||||
@@ -593,8 +587,9 @@ export const NwcConnectionModel = types.model('NwcConnection', {
|
||||
// only early errors are immediately returned
|
||||
nwcResponse = yield self.handlePayInvoice(nwcRequest, requestEvent) as Promise<NwcError | undefined>
|
||||
|
||||
// no early error, transfer initiated, exit and create response in transfer result event handler
|
||||
if(!nwcResponse) {
|
||||
// no early error, transfer initiated, register transfer result event handler and exit
|
||||
if(!nwcResponse) {
|
||||
EventEmitter.on('ev_transferTask_result', self.handleTransferTaskResult)
|
||||
return
|
||||
}
|
||||
break
|
||||
@@ -648,34 +643,6 @@ export const NwcStoreModel = types
|
||||
}
|
||||
}))
|
||||
.actions(self => ({
|
||||
handleTransferResult: flow(function* handleTransferResult(result: TransactionTaskResult) {
|
||||
// find connection the nwc request is sent to
|
||||
const targetConnection = self.nwcConnections.find(c =>
|
||||
c.connectionPubkey === result.nwcEvent?.pubkey
|
||||
)
|
||||
|
||||
if(!targetConnection) {
|
||||
log.error('Missing connection matching event pubkey', {pubkey: result.nwcEvent?.pubkey})
|
||||
return
|
||||
}
|
||||
|
||||
yield targetConnection.handleTransferTaskResult(result)
|
||||
|
||||
}),
|
||||
handleTopupResult: flow(function* handleTopupResult(result: TransactionTaskResult) {
|
||||
// find connection the nwc request is sent to
|
||||
const targetConnection = self.nwcConnections.find(c =>
|
||||
c.connectionPubkey === result.nwcEvent?.pubkey
|
||||
)
|
||||
|
||||
if(!targetConnection) {
|
||||
log.error('Missing connection matching event pubkey', {pubkey: result.nwcEvent?.pubkey})
|
||||
return
|
||||
}
|
||||
|
||||
yield targetConnection.handleTopupTaskResult(result)
|
||||
|
||||
}),
|
||||
resetDailyLimits () {
|
||||
for (const c of self.nwcConnections) {
|
||||
if(!isSameDay(c.currentDay, new Date())) {
|
||||
@@ -794,16 +761,12 @@ export const NwcStoreModel = types
|
||||
}
|
||||
})
|
||||
|
||||
EventEmitter.on('ev_transferTask_result', self.handleTransferResult)
|
||||
EventEmitter.on('ev_nwcTransferTask_result', self.handleTransferResult)
|
||||
EventEmitter.on('ev_topupTask_result', self.handleTopupResult)
|
||||
|
||||
} catch (e: any) {
|
||||
log.error(e.name, e.message)
|
||||
return
|
||||
}
|
||||
},
|
||||
handleNwcRequestFromNotification: flow(function* handleNwcRequestFromNotification(event: NostrEvent) {
|
||||
handleNwcRequestFromNotification: flow(function* handleNwcRequestFromNotification(event: NostrEvent, decryptedNwcRequest: NwcRequest) {
|
||||
// find connection the nwc request is sent to
|
||||
const targetConnection = self.nwcConnections.find(c =>
|
||||
c.connectionPubkey === event.pubkey
|
||||
@@ -814,12 +777,7 @@ export const NwcStoreModel = types
|
||||
return
|
||||
}
|
||||
|
||||
yield targetConnection.handleRequest(event)
|
||||
|
||||
EventEmitter.on('ev_transferTask_result', self.handleTransferResult)
|
||||
EventEmitter.on('ev_nwcTransferTask_result', self.handleTransferResult)
|
||||
EventEmitter.on('ev_topupTask_result', self.handleTopupResult)
|
||||
|
||||
yield targetConnection.handleRequest(event, decryptedNwcRequest)
|
||||
})
|
||||
}))
|
||||
.views(self => ({
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import notifee, { AndroidImportance, AuthorizationStatus } from '@notifee/react-native'
|
||||
import { colors } from '../theme';
|
||||
import { log } from './logService';
|
||||
import notifee, { AndroidColor, 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 messaging, { FirebaseMessagingTypes } from '@react-native-firebase/messaging'
|
||||
import { MintUnit, formatCurrency, getCurrency } from './wallet/currency'
|
||||
import { NostrClient, NostrEvent, NostrProfile } from './nostrService'
|
||||
import AppError, { Err } from '../utils/AppError'
|
||||
import { Platform } from 'react-native'
|
||||
import { RootStoreModel, rootStoreInstance, setupRootStore } from '../models';
|
||||
import { ProofsStoreModel } from '../models/ProofsStore';
|
||||
import { rootStoreInstance, setupRootStore } from '../models'
|
||||
import { NwcRequest, nwcPngUrl } from '../models/NwcStore';
|
||||
|
||||
export type NotifyReceiveToLnurlData = {
|
||||
type: 'NotifyReceiveToLnurlData',
|
||||
@@ -29,6 +29,60 @@ export type NotifyNwcRequestData = {
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_CHANNEL_ID = 'default'
|
||||
const DEFAULT_CHANNEL_NAME = 'Minibits notifications'
|
||||
|
||||
const NWC_CHANNEL_ID = 'nwcDefault';
|
||||
const NWC_CHANNEL_NAME = 'Minibits NWC payment'
|
||||
|
||||
export const initNotifications = async () => {
|
||||
let enabled = await areNotificationsEnabled()
|
||||
log.trace(`[initNotifications] Push notifications are ${enabled ? 'enabled' : 'disabled'}.`)
|
||||
|
||||
if(!enabled) return
|
||||
const {walletProfileStore} = rootStoreInstance
|
||||
|
||||
await messaging().registerDeviceForRemoteMessages()
|
||||
const deviceToken = await messaging().getToken()
|
||||
log.trace(`[initNotifications] Device token: ${deviceToken}`)
|
||||
|
||||
if(walletProfileStore.pubkey && deviceToken) {
|
||||
// if device token changed, update the server
|
||||
if(deviceToken !== walletProfileStore.device) {
|
||||
await walletProfileStore.setDevice(deviceToken)
|
||||
}
|
||||
}
|
||||
|
||||
messaging().onTokenRefresh(token => {
|
||||
if(token !== walletProfileStore.device) {
|
||||
walletProfileStore.setDevice(token)
|
||||
}
|
||||
})
|
||||
|
||||
notifee.isChannelCreated(DEFAULT_CHANNEL_ID).then(isCreated => {
|
||||
if (!isCreated) {
|
||||
notifee.createChannel({
|
||||
id: DEFAULT_CHANNEL_ID,
|
||||
name: DEFAULT_CHANNEL_NAME,
|
||||
vibration: true,
|
||||
importance: AndroidImportance.HIGH,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
notifee.isChannelCreated(NWC_CHANNEL_ID).then(isCreated => {
|
||||
if (!isCreated) {
|
||||
notifee.createChannel({
|
||||
id: NWC_CHANNEL_ID,
|
||||
name: NWC_CHANNEL_NAME,
|
||||
sound: 'default',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
}
|
||||
|
||||
// Remote notification receive handler
|
||||
const onForegroundNotification = async function(remoteMessage: FirebaseMessagingTypes.RemoteMessage) {
|
||||
log.trace('[onForegroundNotification]', {remoteMessage})
|
||||
@@ -92,15 +146,37 @@ const _receiveToLnurlHandler = async function(remoteData: NotifyReceiveToLnurlDa
|
||||
const _nwcRequestHandler = async function(remoteData: NotifyNwcRequestData) {
|
||||
const {requestEvent} = remoteData.data
|
||||
const {nwcStore} = rootStoreInstance
|
||||
|
||||
// If app is killed, state is not loaded
|
||||
|
||||
if(nwcStore.all.length === 0) {
|
||||
await setupRootStore(rootStoreInstance)
|
||||
}
|
||||
|
||||
log.trace('[_nwcRequestHandler]', {connections: nwcStore.all})
|
||||
|
||||
await nwcStore.handleNwcRequestFromNotification(requestEvent)
|
||||
const decryptedContent = await NostrClient.decryptNip04(requestEvent.pubkey, requestEvent.content)
|
||||
const nwcRequest: NwcRequest = JSON.parse(decryptedContent)
|
||||
|
||||
if(nwcRequest.method === 'pay_invoice' || nwcRequest.method === 'make_invoice') {
|
||||
|
||||
log.trace('[_nwcRequestHandler] Starting foreground service')
|
||||
|
||||
const {tags, ...cleanedRequestEvent} = requestEvent
|
||||
|
||||
await notifee.displayNotification({
|
||||
title: NWC_CHANNEL_NAME,
|
||||
body: 'Nostr wallet command is being processed.',
|
||||
android: {
|
||||
channelId: NWC_CHANNEL_ID,
|
||||
asForegroundService: true,
|
||||
largeIcon: nwcPngUrl,
|
||||
importance: AndroidImportance.HIGH,
|
||||
progress: {
|
||||
indeterminate: true,
|
||||
},
|
||||
},
|
||||
data: cleanedRequestEvent, // Pass the task data to the foreground service
|
||||
})
|
||||
} else {
|
||||
await nwcStore.handleNwcRequestFromNotification(requestEvent, nwcRequest)
|
||||
}
|
||||
}
|
||||
|
||||
const _getRemoteData = async function(remoteMessage: FirebaseMessagingTypes.RemoteMessage) {
|
||||
@@ -130,21 +206,13 @@ const createLocalNotification = async function (title: string, body: string, lar
|
||||
await notifee.requestPermission()
|
||||
}
|
||||
|
||||
// Create a channel (required for Android)
|
||||
const channelId = await notifee.createChannel({
|
||||
id: 'default',
|
||||
name: 'Minibits notifications',
|
||||
vibration: true,
|
||||
importance: AndroidImportance.HIGH,
|
||||
})
|
||||
|
||||
// Display a notification
|
||||
if(largeIcon) {
|
||||
const notificationId = await notifee.displayNotification({
|
||||
title,
|
||||
body,
|
||||
android: {
|
||||
channelId,
|
||||
channelId: DEFAULT_CHANNEL_ID,
|
||||
color: colors.palette.success200,
|
||||
largeIcon,
|
||||
pressAction: {
|
||||
@@ -159,7 +227,7 @@ const createLocalNotification = async function (title: string, body: string, lar
|
||||
title,
|
||||
body,
|
||||
android: {
|
||||
channelId,
|
||||
channelId: DEFAULT_CHANNEL_ID,
|
||||
color: colors.palette.success200,
|
||||
pressAction: {
|
||||
id: 'default',
|
||||
@@ -213,11 +281,16 @@ const areNotificationsEnabled = async function (): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
|
||||
const stopForegroundService = async function (): Promise<void> {
|
||||
await notifee.stopForegroundService()
|
||||
}
|
||||
|
||||
export const NotificationService = {
|
||||
onForegroundNotification,
|
||||
onBackgroundNotification,
|
||||
createLocalNotification,
|
||||
onBackgroundNotification,
|
||||
onForegroundNotification,
|
||||
// updateLocalNotification,
|
||||
// cancelNotification,
|
||||
areNotificationsEnabled
|
||||
areNotificationsEnabled,
|
||||
stopForegroundService
|
||||
}
|
||||
@@ -1,393 +0,0 @@
|
||||
import {CashuUtils, ProofV3, TokenEntryV3} from '../cashu/cashuUtils'
|
||||
import AppError, {Err} from '../../utils/AppError'
|
||||
import {MeltQuoteResponse, MeltQuoteState} from '@cashu/cashu-ts'
|
||||
import {rootStoreInstance} from '../../models'
|
||||
import { TransactionTaskResult, WalletTask } from '../walletService'
|
||||
import { MintBalance, MintProofsCounter } from '../../models/Mint'
|
||||
import { Proof } from '../../models/Proof'
|
||||
import { Transaction, TransactionData, TransactionStatus, TransactionType } from '../../models/Transaction'
|
||||
import { log } from '../logService'
|
||||
import { WalletUtils } from './utils'
|
||||
import {isBefore} from 'date-fns'
|
||||
import { MintUnit, formatCurrency, getCurrency } from './currency'
|
||||
import { NostrEvent } from '../nostrService'
|
||||
import { MinibitsClient } from '../minibitsService'
|
||||
import { getSnapshot } from 'mobx-state-tree'
|
||||
import { MINIBITS_MINT_URL } from '@env'
|
||||
import { receiveSync } from './receiveTask'
|
||||
|
||||
const {
|
||||
transactionsStore,
|
||||
mintsStore,
|
||||
proofsStore,
|
||||
walletStore
|
||||
} = rootStoreInstance
|
||||
|
||||
const NWC_TRANSFER = 'nwcTransferTask'
|
||||
|
||||
export const nwcTransferTask = async function (
|
||||
mintBalanceToTransferFrom: MintBalance,
|
||||
amountToTransfer: number,
|
||||
feeReserve: number,
|
||||
unit: MintUnit,
|
||||
memo: string,
|
||||
invoiceExpiry: Date,
|
||||
encodedInvoice: string,
|
||||
nwcEvent: NostrEvent
|
||||
) : Promise<TransactionTaskResult> {
|
||||
|
||||
const mintUrl = mintBalanceToTransferFrom.mintUrl
|
||||
const mintInstance = mintsStore.findByUrl(mintUrl)
|
||||
|
||||
if(mintUrl !== MINIBITS_MINT_URL) {
|
||||
throw new AppError(
|
||||
Err.VALIDATION_ERROR,
|
||||
'Payment of NWC invoice on the Minibits server is supported only for ecash issued by Minibits mint.',
|
||||
{mintBalanceToTransferFrom}
|
||||
)
|
||||
}
|
||||
|
||||
// TODO refresh - balance might be outdated if it waits in queue before other txs
|
||||
log.debug('[nwcTransfer]', 'mintBalanceToTransferFrom', {mintBalanceToTransferFrom})
|
||||
log.debug('[nwcTransfer]', 'amountToTransfer', {amountToTransfer, feeReserve})
|
||||
|
||||
// create draft transaction
|
||||
const transactionData: TransactionData[] = [
|
||||
{
|
||||
status: TransactionStatus.DRAFT,
|
||||
mintBalanceToTransferFrom,
|
||||
amountToTransfer,
|
||||
feeReserve,
|
||||
unit,
|
||||
encodedInvoice,
|
||||
isNwc: nwcEvent ? true : false,
|
||||
createdAt: new Date(),
|
||||
}
|
||||
]
|
||||
|
||||
let transaction: Transaction | undefined = undefined
|
||||
let meltQuote: MeltQuoteResponse | undefined = undefined
|
||||
let proofsToMeltFrom: Proof[] = []
|
||||
let proofsToMeltFromAmount: number = 0
|
||||
let meltFeeReserve: number = 0
|
||||
|
||||
try {
|
||||
const newTransaction = {
|
||||
type: TransactionType.NWC_TRANSFER,
|
||||
amount: amountToTransfer,
|
||||
fee: feeReserve,
|
||||
unit,
|
||||
data: JSON.stringify(transactionData),
|
||||
memo,
|
||||
mint: mintBalanceToTransferFrom.mintUrl,
|
||||
status: TransactionStatus.DRAFT,
|
||||
}
|
||||
|
||||
// store tx in db and in the model
|
||||
transaction = await transactionsStore.addTransaction(newTransaction)
|
||||
const transactionId = transaction.id
|
||||
|
||||
if (amountToTransfer + feeReserve > mintBalanceToTransferFrom.balances[unit]!) {
|
||||
throw new AppError(
|
||||
Err.VALIDATION_ERROR,
|
||||
'Mint balance is insufficient to cover the amount to transfer with the expected Lightning fees.',
|
||||
{transactionId}
|
||||
)
|
||||
}
|
||||
|
||||
if(isBefore(invoiceExpiry, new Date())) {
|
||||
throw new AppError(
|
||||
Err.VALIDATION_ERROR,
|
||||
'This invoice has already expired and can not be paid.',
|
||||
{invoiceExpiry, transactionId}
|
||||
)
|
||||
}
|
||||
|
||||
if (!mintInstance) {
|
||||
throw new AppError(
|
||||
Err.VALIDATION_ERROR,
|
||||
'Could not find mint', {mintUrl, transactionId}
|
||||
)
|
||||
}
|
||||
|
||||
// calculate fees charged by mint for melt transaction to prepare enough proofs
|
||||
const proofsFromMint = proofsStore.getByMint(mintUrl, {isPending: false, unit})
|
||||
const totalAmountFromMint = CashuUtils.getProofsAmount(proofsFromMint)
|
||||
|
||||
proofsToMeltFrom = CashuUtils.getProofsToSend(
|
||||
amountToTransfer + feeReserve,
|
||||
proofsFromMint
|
||||
)
|
||||
|
||||
proofsToMeltFromAmount = CashuUtils.getProofsAmount(proofsToMeltFrom)
|
||||
meltFeeReserve = mintInstance.getMintFeeReserve(proofsToMeltFrom)
|
||||
const amountWithFees = amountToTransfer + feeReserve + meltFeeReserve
|
||||
|
||||
if (totalAmountFromMint < amountWithFees) {
|
||||
throw new AppError(
|
||||
Err.VALIDATION_ERROR,
|
||||
'There is not enough funds to send this amount.',
|
||||
{totalAmountFromMint, amountWithFees, transactionId, caller: 'nwcTransferTask'},
|
||||
)
|
||||
}
|
||||
|
||||
// exact match or min number of proofs that matches the amount
|
||||
if(meltFeeReserve > 0) {
|
||||
proofsToMeltFrom = CashuUtils.getProofsToSend(
|
||||
amountWithFees,
|
||||
proofsFromMint
|
||||
)
|
||||
|
||||
proofsToMeltFromAmount = CashuUtils.getProofsAmount(proofsToMeltFrom)
|
||||
}
|
||||
|
||||
proofsStore.removeProofs(proofsToMeltFrom)
|
||||
WalletUtils.addCashuProofs(
|
||||
mintUrl,
|
||||
proofsToMeltFrom,
|
||||
{
|
||||
unit,
|
||||
transactionId: transaction.id,
|
||||
isPending: true
|
||||
}
|
||||
)
|
||||
|
||||
log.trace('[nwcTransfer]', 'Prepared proofsToMeltFrom proofs', {
|
||||
proofsToMeltFromAmount,
|
||||
transactionId,
|
||||
unit,
|
||||
})
|
||||
|
||||
// Update transaction status
|
||||
transactionData.push({
|
||||
status: TransactionStatus.PREPARED,
|
||||
proofsToMeltFromAmount,
|
||||
lightningFeeReserve: feeReserve,
|
||||
meltFeeReserve,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
transaction.setStatus(
|
||||
TransactionStatus.PREPARED,
|
||||
JSON.stringify(transactionData),
|
||||
)
|
||||
|
||||
const cleanedproofsToMeltFrom = proofsToMeltFrom.map(proof => {
|
||||
const {mintUrl, unit, tId, ...rest} = getSnapshot(proof)
|
||||
return rest
|
||||
})
|
||||
|
||||
const inputTokenEntry: TokenEntryV3 = {
|
||||
mint: mintUrl,
|
||||
proofs: cleanedproofsToMeltFrom,
|
||||
}
|
||||
|
||||
const tokenToPayFrom = {
|
||||
token: [inputTokenEntry],
|
||||
unit
|
||||
}
|
||||
|
||||
const inputToken = CashuUtils.encodeToken(tokenToPayFrom)
|
||||
|
||||
transaction.setInputToken(inputToken)
|
||||
|
||||
const result = await MinibitsClient.payNwcTransfer(
|
||||
encodedInvoice,
|
||||
{
|
||||
token: [inputTokenEntry],
|
||||
unit
|
||||
}
|
||||
)
|
||||
|
||||
meltQuote = result.meltQuote
|
||||
const tokenToReturn = result.tokenToReturn // this is server wallet ecash, needs swap
|
||||
|
||||
if (meltQuote.state === MeltQuoteState.PAID) {
|
||||
|
||||
log.debug('[nwcTransfer] Invoice PAID', {
|
||||
transactionId
|
||||
})
|
||||
|
||||
// Save preimage asap
|
||||
if(meltQuote.payment_preimage) {
|
||||
transaction.setProof(meltQuote.payment_preimage)
|
||||
}
|
||||
|
||||
// If nothing was returned, all reserves were spent on fees
|
||||
let totalFeePaid = proofsToMeltFromAmount - amountToTransfer
|
||||
let lightningFeePaid = totalFeePaid - meltFeeReserve
|
||||
let meltFeePaid = meltFeeReserve
|
||||
let swapFeePaid = 0
|
||||
let returnedAmount
|
||||
|
||||
if (tokenToReturn) {
|
||||
// Save returned token in case receive fails
|
||||
const outputTokenBeforeSwap = CashuUtils.encodeToken(tokenToReturn)
|
||||
transaction.setOutputToken(outputTokenBeforeSwap)
|
||||
|
||||
// Swap returned proofs as they were issued to the server wallet thus are not linked to the wallet seed
|
||||
const receiveResult = await receiveSync(
|
||||
mintUrl,
|
||||
tokenToReturn,
|
||||
memo,
|
||||
transactionId
|
||||
)
|
||||
|
||||
swapFeePaid = receiveResult.swapFeePaid
|
||||
totalFeePaid = totalFeePaid - receiveResult.receivedAmount
|
||||
lightningFeePaid = totalFeePaid - meltFeeReserve - swapFeePaid
|
||||
returnedAmount = receiveResult.receivedAmount
|
||||
|
||||
// re-save with swapped token
|
||||
transaction.setOutputToken(receiveResult.outputToken)
|
||||
}
|
||||
|
||||
// spend pending proofsToMeltFrom reserved for transaction
|
||||
// only after rhe receive passed, in case of exception they remain pending
|
||||
// and are sorted out by syncStateWithMintSync method
|
||||
proofsStore.removeProofs(proofsToMeltFrom as Proof[], true, false)
|
||||
|
||||
// Save final fee in db
|
||||
if(totalFeePaid !== transaction.fee) {
|
||||
transaction.setFee(totalFeePaid)
|
||||
}
|
||||
|
||||
// Update transaction status
|
||||
transactionData.push({
|
||||
status: TransactionStatus.COMPLETED,
|
||||
lightningFeeReserve: meltQuote.fee_reserve,
|
||||
lightningFeePaid,
|
||||
meltFeePaid,
|
||||
swapFeePaid,
|
||||
returnedAmount,
|
||||
preimage: meltQuote.payment_preimage,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
transaction.setStatus(
|
||||
TransactionStatus.COMPLETED,
|
||||
JSON.stringify(transactionData),
|
||||
)
|
||||
|
||||
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance!
|
||||
transaction.setBalanceAfter(balanceAfter)
|
||||
|
||||
return {
|
||||
taskFunction: NWC_TRANSFER,
|
||||
mintUrl,
|
||||
transaction,
|
||||
message: `Lightning invoice has been successfully paid and settled with your Minibits ecash. Fee has been ${formatCurrency(transaction.fee, getCurrency(unit).code)} ${getCurrency(unit).code}.`,
|
||||
lightningFeePaid,
|
||||
meltFeePaid,
|
||||
swapFeePaid,
|
||||
totalFeePaid,
|
||||
meltQuote,
|
||||
preimage: meltQuote.payment_preimage,
|
||||
nwcEvent
|
||||
} as TransactionTaskResult
|
||||
|
||||
} else if(meltQuote.state === MeltQuoteState.PENDING) {
|
||||
|
||||
log.debug('[transfer] Invoice PENDING', {
|
||||
meltQuote,
|
||||
transactionId
|
||||
})
|
||||
|
||||
return {
|
||||
taskFunction: NWC_TRANSFER,
|
||||
mintUrl,
|
||||
transaction,
|
||||
message: `Lightning payment did not complete in time. Your ecash will remain pending until the payment completes or fails.`,
|
||||
meltQuote,
|
||||
nwcEvent
|
||||
} as TransactionTaskResult
|
||||
|
||||
} else {
|
||||
// throw so that proper state of proofs is synced inside the catch block
|
||||
throw new AppError(Err.MINT_ERROR, 'Lightning payment has not been paid.', {
|
||||
state: meltQuote.state,
|
||||
transactionId
|
||||
})
|
||||
}
|
||||
|
||||
} catch (e: any) {
|
||||
if (transaction) {
|
||||
|
||||
let message = e.message
|
||||
|
||||
if (proofsToMeltFrom.length > 0) {
|
||||
// checks and syncs with the mint the real status of the proofs involved in transaction
|
||||
await WalletTask.syncStateWithMintSync(
|
||||
{
|
||||
proofsToSync: proofsToMeltFrom,
|
||||
mintUrl,
|
||||
isPending: true
|
||||
}
|
||||
)
|
||||
|
||||
// force refresh just in case above method did not update the model?
|
||||
const refreshed = transactionsStore.findById(transaction.id)
|
||||
|
||||
if(refreshed?.status === TransactionStatus.PENDING) {
|
||||
log.warn('[transfer]', 'proofsToPay from transfer with error are pending by mint', {
|
||||
proofsToMeltFromAmount,
|
||||
unit,
|
||||
transactionId: transaction.id
|
||||
})
|
||||
|
||||
message = 'Lightning payment did not complete in time. Your ecash will remain pending until the payment completes or fails.'
|
||||
// needs to be returned as error
|
||||
|
||||
} else if(refreshed?.status === TransactionStatus.COMPLETED) {
|
||||
// Likely receiving of change failed due to wallet error. We keep completed as status.
|
||||
log.error('[nwcTransfer]', 'NWC Transfer throwed error but the payment suceeded', {
|
||||
error: e.message,
|
||||
proofsToMeltFromAmount,
|
||||
unit,
|
||||
transactionId: transaction.id
|
||||
})
|
||||
|
||||
if(meltQuote?.payment_preimage) {
|
||||
refreshed.setProof(meltQuote?.payment_preimage)
|
||||
}
|
||||
|
||||
const message = `Lightning invoice has been successfully paid, however some error occured: ${e.message}`
|
||||
|
||||
return {
|
||||
taskFunction: NWC_TRANSFER,
|
||||
mintUrl,
|
||||
transaction,
|
||||
message,
|
||||
meltQuote,
|
||||
preimage: meltQuote?.payment_preimage,
|
||||
nwcEvent
|
||||
} as TransactionTaskResult
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
transactionData.push({
|
||||
status: TransactionStatus.ERROR,
|
||||
error: WalletUtils.formatError(e),
|
||||
createdAt: new Date()
|
||||
})
|
||||
|
||||
transaction.setStatus(
|
||||
TransactionStatus.ERROR,
|
||||
JSON.stringify(transactionData),
|
||||
)
|
||||
}
|
||||
|
||||
log.error('[transfer]', e.name, e.message, e.params)
|
||||
|
||||
return {
|
||||
taskFunction: NWC_TRANSFER,
|
||||
mintUrl,
|
||||
transaction,
|
||||
message: e.message,
|
||||
error: WalletUtils.formatError(e),
|
||||
meltQuote,
|
||||
nwcEvent
|
||||
} as TransactionTaskResult
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,6 @@ import { receiveTask, receiveOfflinePrepareTask, receiveOfflineCompleteTask} fro
|
||||
import { sendTask } from './wallet/sendTask'
|
||||
import { topupTask } from './wallet/topupTask'
|
||||
import { transferTask } from './wallet/transferTask'
|
||||
import { nwcTransferTask } from './wallet/nwcTransferTask'
|
||||
import { revertTask } from './wallet/revertTask'
|
||||
import { WalletUtils } from './wallet/utils'
|
||||
import { NotificationService } from './notificationService'
|
||||
@@ -73,16 +72,6 @@ type WalletTaskService = {
|
||||
encodedInvoice: string,
|
||||
nwcEvent?: NostrEvent
|
||||
) => Promise<void>
|
||||
nwcTransfer: (
|
||||
mintBalanceToTransferFrom: MintBalance,
|
||||
amountToTransfer: number,
|
||||
feeReserve: number,
|
||||
unit: MintUnit,
|
||||
memo: string,
|
||||
invoiceExpiry: Date,
|
||||
encodedInvoice: string,
|
||||
nwcEvent: NostrEvent
|
||||
) => Promise<void>
|
||||
receive: (
|
||||
token: TokenV3,
|
||||
amountToReceive: number,
|
||||
@@ -212,34 +201,6 @@ const transfer = async function (
|
||||
}
|
||||
|
||||
|
||||
const nwcTransfer = async function (
|
||||
mintBalanceToTransferFrom: MintBalance,
|
||||
amountToTransfer: number,
|
||||
feeReserve: number,
|
||||
unit: MintUnit,
|
||||
memo: string,
|
||||
invoiceExpiry: Date,
|
||||
encodedInvoice: string,
|
||||
nwcEvent: NostrEvent
|
||||
): Promise<void> {
|
||||
const now = new Date().getTime()
|
||||
SyncQueue.addPrioritizedTask(
|
||||
`nwcTransferTask-${now}`,
|
||||
async () => await nwcTransferTask(
|
||||
mintBalanceToTransferFrom,
|
||||
amountToTransfer,
|
||||
feeReserve,
|
||||
unit,
|
||||
memo,
|
||||
invoiceExpiry,
|
||||
encodedInvoice,
|
||||
nwcEvent
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
const receive = async function (
|
||||
token: TokenV3,
|
||||
amountToReceive: number,
|
||||
@@ -650,8 +611,7 @@ const _syncStateWithMintTask = async function (
|
||||
isPending
|
||||
})
|
||||
|
||||
// If some of the pending by wallet proofs are either pending nor spent by mint
|
||||
// can happen in nwcTransfer if lightning payment succeeds but then receive of returned token fails
|
||||
// If some of the pending by wallet proofs are neither pending nor spent by mint
|
||||
if (unspentByMintProofs.length > 0 && isPending) {
|
||||
// remove it from pending proofs in the wallet
|
||||
proofsStore.removeProofs(unspentByMintProofs as Proof[], true, true)
|
||||
@@ -1863,8 +1823,7 @@ export const WalletTask: WalletTaskService = {
|
||||
receiveOfflineComplete,
|
||||
send,
|
||||
sendAll,
|
||||
transfer,
|
||||
nwcTransfer,
|
||||
transfer,
|
||||
topup,
|
||||
revert
|
||||
}
|
||||
|
||||
@@ -2882,12 +2882,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@notifee/react-native@npm:^9.0.2":
|
||||
version: 9.0.2
|
||||
resolution: "@notifee/react-native@npm:9.0.2"
|
||||
"@notifee/react-native@npm:^9.1.3":
|
||||
version: 9.1.3
|
||||
resolution: "@notifee/react-native@npm:9.1.3"
|
||||
peerDependencies:
|
||||
react-native: "*"
|
||||
checksum: 2b46e360096dffea51803ff28a5e106d0bdea09651a72487728ee925f1748b2a374377c4db9e85f62fda8d7e16b0e0a2d7dcd4078c0342bac522bec364ccf21a
|
||||
checksum: cf37a6ab404937be62b2c4a94fdd8ee735eb1a8adfc8fc087dd4a3ce6d8234bc30b3417a4be180de5da2ead2d1a3c1ddb8519cb98d24d062fd4d858ec39d9337
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -9436,7 +9436,7 @@ __metadata:
|
||||
"@gandlaf21/bc-ur": ^1.1.12
|
||||
"@gandlaf21/bolt11-decode": ^3.1.1
|
||||
"@gocodingnow/rn-size-matters": ^0.0.4
|
||||
"@notifee/react-native": ^9.0.2
|
||||
"@notifee/react-native": ^9.1.3
|
||||
"@react-native-clipboard/clipboard": ^1.14.1
|
||||
"@react-native-community/netinfo": ^11.4.0
|
||||
"@react-native-firebase/app": ^20.5.0
|
||||
|
||||
Reference in New Issue
Block a user