Implement foreground service to handle long running NWC tasks

This commit is contained in:
minibits-cash
2024-12-02 12:41:02 +01:00
parent 28c8413876
commit 19c580f7f0
10 changed files with 180 additions and 577 deletions
+2 -2
View File
@@ -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 {
+1
View File
@@ -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"
+25 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 => ({
+97 -24
View File
@@ -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
}
-393
View File
@@ -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
}
}
+2 -43
View File
@@ -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
}
+5 -5
View File
@@ -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