From 19c580f7f0bf82eea710e259b96b8b54369159b2 Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Mon, 2 Dec 2024 12:41:02 +0100 Subject: [PATCH] Implement foreground service to handle long running NWC tasks --- android/app/build.gradle | 4 +- android/app/src/main/AndroidManifest.xml | 1 + index.js | 28 +- package.json | 4 +- src/App.tsx | 29 +- src/models/NwcStore.ts | 122 +++---- src/services/notificationService.ts | 121 +++++-- src/services/wallet/nwcTransferTask.ts | 393 ----------------------- src/services/walletService.ts | 45 +-- yarn.lock | 10 +- 10 files changed, 180 insertions(+), 577 deletions(-) delete mode 100644 src/services/wallet/nwcTransferTask.ts diff --git a/android/app/build.gradle b/android/app/build.gradle index a01b10a8..887eff87 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -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 { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1832f71c..3bf37591 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + } +// 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) diff --git a/package.json b/package.json index b83a8526..f1cb408c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/App.tsx b/src/App.tsx index 008aa8fd..4d426b9e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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') diff --git a/src/models/NwcStore.ts b/src/models/NwcStore.ts index ac86bd65..ed4f0884 100644 --- a/src/models/NwcStore.ts +++ b/src/models/NwcStore.ts @@ -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( `${self.name} - 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( `${self.name} - 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 - // 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 - // 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 => ({ diff --git a/src/services/notificationService.ts b/src/services/notificationService.ts index d3139ee0..3378a02b 100644 --- a/src/services/notificationService.ts +++ b/src/services/notificationService.ts @@ -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 { return false } +const stopForegroundService = async function (): Promise { + await notifee.stopForegroundService() +} + export const NotificationService = { - onForegroundNotification, - onBackgroundNotification, createLocalNotification, + onBackgroundNotification, + onForegroundNotification, // updateLocalNotification, // cancelNotification, - areNotificationsEnabled + areNotificationsEnabled, + stopForegroundService } \ No newline at end of file diff --git a/src/services/wallet/nwcTransferTask.ts b/src/services/wallet/nwcTransferTask.ts deleted file mode 100644 index 37dcbefb..00000000 --- a/src/services/wallet/nwcTransferTask.ts +++ /dev/null @@ -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 { - - 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 - } -} \ No newline at end of file diff --git a/src/services/walletService.ts b/src/services/walletService.ts index 9d4f1461..0b2462cb 100644 --- a/src/services/walletService.ts +++ b/src/services/walletService.ts @@ -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 - nwcTransfer: ( - mintBalanceToTransferFrom: MintBalance, - amountToTransfer: number, - feeReserve: number, - unit: MintUnit, - memo: string, - invoiceExpiry: Date, - encodedInvoice: string, - nwcEvent: NostrEvent - ) => Promise 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 { - 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 } diff --git a/yarn.lock b/yarn.lock index 0ed83388..dcddb96b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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