From ff8ac68af7b4fad550b3b9988093a77566dd42df Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 22 Jul 2026 14:05:40 +0200 Subject: [PATCH] fix: refresh receive requests after payment --- .../androidServices/LightningNodeService.kt | 20 +- .../main/java/to/bitkit/data/CacheStore.kt | 37 +- .../commands/NotifyPaymentReceivedHandler.kt | 68 +++- .../to/bitkit/repositories/ActivityRepo.kt | 2 + .../to/bitkit/repositories/LightningRepo.kt | 101 ++++- .../java/to/bitkit/repositories/WalletRepo.kt | 60 ++- .../java/to/bitkit/viewmodels/AppViewModel.kt | 75 +++- .../LightningNodeServiceTest.kt | 91 ++++- .../java/to/bitkit/data/AppCacheDataTest.kt | 50 +++ .../java/to/bitkit/data/CacheStoreTest.kt | 83 ++++ .../NotifyPaymentReceivedHandlerTest.kt | 81 ++++ .../bitkit/repositories/LightningRepoTest.kt | 148 +++++++ .../to/bitkit/repositories/WalletRepoTest.kt | 155 +++++++- .../viewmodels/AppViewModelSendFlowTest.kt | 363 +++++++++++++++++- changelog.d/next/1086.fixed.md | 1 + 15 files changed, 1279 insertions(+), 56 deletions(-) create mode 100644 app/src/test/java/to/bitkit/data/AppCacheDataTest.kt create mode 100644 app/src/test/java/to/bitkit/data/CacheStoreTest.kt create mode 100644 changelog.d/next/1086.fixed.md diff --git a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt index a1d248de4b..79849ab9c9 100644 --- a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -15,6 +15,7 @@ import androidx.core.content.ContextCompat import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.lightningdevkit.ldknode.Event import to.bitkit.App import to.bitkit.R @@ -106,10 +107,17 @@ class LightningNodeService : Service() { if (event !is Event.PaymentReceived && event !is Event.OnchainTransactionReceived) return val command = NotifyPaymentReceived.Command.from(event, includeNotification = true) ?: return - notifyPaymentReceivedHandler(command).onSuccess { - Logger.debug("Payment notification result: $it", context = TAG) - if (it !is NotifyPaymentReceived.Result.ShowNotification) return - showPaymentNotification(it.sheet, it.notification) + notifyPaymentReceivedHandler(command).onSuccess { result -> + Logger.debug("Handled payment notification with result '$result'", context = TAG) + if (result !is NotifyPaymentReceived.Result.ShowNotification) return + withContext(uiDispatcher) { + notifyPaymentReceivedHandler.present( + command = command, + canPresent = { App.currentActivity?.value == null }, + ) { + showPaymentNotification(result.sheet, result.notification) + } + } } } @@ -132,10 +140,6 @@ class LightningNodeService : Service() { sheet: NewTransactionSheetDetails, notification: NotificationDetails, ) { - if (App.currentActivity?.value != null) { - Logger.debug("Skipping payment notification: activity is active", context = TAG) - return - } Logger.debug("Showing payment notification: ${notification.title}", context = TAG) serviceScope.launch { cacheStore.setBackgroundReceive(sheet) } pushNotification(notification.title, notification.body) diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index 477d4f3c94..3a0bf991b5 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -55,14 +55,40 @@ class CacheStore @Inject constructor( store.updateData { it.copy(onchainAddress = address) } } - suspend fun saveBolt11(bolt11: String) { - store.updateData { it.copy(bolt11 = bolt11) } + suspend fun saveBolt11(bolt11: String, paymentHash: String) { + store.updateData { it.copy(bolt11 = bolt11, bolt11PaymentHash = paymentHash) } } suspend fun setBip21(bip21: String) { store.updateData { it.copy(bip21 = bip21) } } + suspend fun invalidateReceiveLightningInvoice(expectedBolt11: String): Boolean { + var invalidated = false + store.updateData { + if (it.bolt11 == expectedBolt11) { + invalidated = true + it.invalidateReceiveLightningInvoice() + } else { + it + } + } + return invalidated + } + + suspend fun invalidateReceiveOnchainAddress(expectedAddress: String): Boolean { + var invalidated = false + store.updateData { + if (it.onchainAddress == expectedAddress) { + invalidated = true + it.invalidateReceiveOnchainAddress() + } else { + it + } + } + return invalidated + } + suspend fun cacheBalance(balanceState: BalanceState) { store.updateData { it.copy(balance = balanceState) } } @@ -123,6 +149,7 @@ data class AppCacheData( val paidOrders: Map = mapOf(), val onchainAddress: String = "", val bolt11: String = "", + val bolt11PaymentHash: String = "", val bip21: String = "", val balance: BalanceState? = null, val backupStatuses: Map = mapOf(), @@ -132,5 +159,9 @@ data class AppCacheData( val addressSearchLastUsedReceiveIndexes: Map = mapOf(), val addressSearchLastUsedChangeIndexes: Map = mapOf(), ) { - fun resetBip21() = copy(bip21 = "", bolt11 = "", onchainAddress = "") + fun resetBip21() = copy(bip21 = "", bolt11 = "", bolt11PaymentHash = "", onchainAddress = "") + + fun invalidateReceiveLightningInvoice() = copy(bip21 = "", bolt11 = "", bolt11PaymentHash = "") + + fun invalidateReceiveOnchainAddress() = copy(bip21 = "", onchainAddress = "") } diff --git a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt index 179b624e18..5aa105096a 100644 --- a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt +++ b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import to.bitkit.di.IoDispatcher +import to.bitkit.ext.runSuspendCatching import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType @@ -19,16 +20,21 @@ class NotifyPaymentReceivedHandler @Inject constructor( private val activityRepo: ActivityRepo, private val receivedNotificationContent: ReceivedNotificationContent, ) { + private val presentationClaimsLock = Any() + private val presentationClaims = mutableSetOf() + suspend operator fun invoke( command: NotifyPaymentReceived.Command, ): Result = withContext(ioDispatcher) { - runCatching { + runSuspendCatching { + if (isPresentationClaimed(command)) return@runSuspendCatching NotifyPaymentReceived.Result.Skip + val shouldShow = when (command) { is NotifyPaymentReceived.Command.Lightning -> shouldShowLightning(command) is NotifyPaymentReceived.Command.Onchain -> shouldShowOnchain(command) } - if (!shouldShow) return@runCatching NotifyPaymentReceived.Result.Skip + if (!shouldShow) return@runSuspendCatching NotifyPaymentReceived.Result.Skip val details = buildSheetDetails(command) @@ -43,12 +49,50 @@ class NotifyPaymentReceivedHandler @Inject constructor( } } + fun claimPresentation(command: NotifyPaymentReceived.Command): Boolean = claimPresentation(command) { true } + + fun claimPresentation( + command: NotifyPaymentReceived.Command, + canPresent: () -> Boolean, + ): Boolean { + val key = presentationKey(command) ?: return false + return synchronized(presentationClaimsLock) { + canPresent() && presentationClaims.add(key) + } + } + + suspend fun present( + command: NotifyPaymentReceived.Command, + canPresent: () -> Boolean = { true }, + block: () -> Unit, + ): Boolean { + if (!claimPresentation(command, canPresent)) return false + block() + recordPresentation(command) + return true + } + + suspend fun recordPresentation(command: NotifyPaymentReceived.Command) { + withContext(ioDispatcher) { + runSuspendCatching { markAsSeen(command) } + .onFailure { Logger.error("Failed to mark payment notification as presented", it, context = TAG) } + } + } + + private fun isPresentationClaimed(command: NotifyPaymentReceived.Command): Boolean { + val key = presentationKey(command) ?: return false + return synchronized(presentationClaimsLock) { key in presentationClaims } + } + + private fun presentationKey(command: NotifyPaymentReceived.Command): String? = when (command) { + is NotifyPaymentReceived.Command.Lightning -> command.event.paymentId?.let { "lightning:$it" } + is NotifyPaymentReceived.Command.Onchain -> "onchain:${command.event.txid}" + } + private suspend fun shouldShowLightning(command: NotifyPaymentReceived.Command.Lightning): Boolean { val paymentId = command.event.paymentId ?: return false delay(DELAY_FOR_ACTIVITY_SYNC_MS) - if (activityRepo.isActivitySeen(paymentId)) return false - activityRepo.markActivityAsSeen(paymentId) - return true + return !activityRepo.isActivitySeen(paymentId) } private suspend fun shouldShowOnchain(command: NotifyPaymentReceived.Command.Onchain): Boolean { @@ -60,12 +104,20 @@ class NotifyPaymentReceivedHandler @Inject constructor( command.event.txid, command.event.details.amountSats.toULong(), ) - if (shouldShowSheet) { - activityRepo.markOnchainActivityAsSeen(command.event.txid) - } return shouldShowSheet } + private suspend fun markAsSeen(command: NotifyPaymentReceived.Command) { + when (command) { + is NotifyPaymentReceived.Command.Lightning -> { + val paymentId = command.event.paymentId ?: return + activityRepo.markActivityAsSeen(paymentId) + } + + is NotifyPaymentReceived.Command.Onchain -> activityRepo.markOnchainActivityAsSeen(command.event.txid) + } + } + private suspend fun retryShouldShowReceivedSheet(txid: String, amountSats: ULong): Boolean { repeat(MAX_RETRIES) { if (activityRepo.shouldShowReceivedSheet(txid, amountSats)) return true diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 9de8bc1ba4..7c94a9222d 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -254,6 +254,8 @@ class ActivityRepo @Inject constructor( notifyActivitiesChanged() } + suspend fun notifyPaymentActivityChanged() = notifyActivitiesChanged() + suspend fun shouldShowReceivedSheet(txid: String, value: ULong): Boolean { return coreService.activity.shouldShowReceivedSheet(txid, value) } diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 92ed77da02..74dd9b49a0 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -40,6 +40,7 @@ import kotlinx.coroutines.withTimeoutOrNull import org.lightningdevkit.ldknode.Address import org.lightningdevkit.ldknode.BalanceDetails import org.lightningdevkit.ldknode.BestBlock +import org.lightningdevkit.ldknode.Bolt11Invoice import org.lightningdevkit.ldknode.ChannelConfig import org.lightningdevkit.ldknode.ChannelDataMigration import org.lightningdevkit.ldknode.ChannelDetails @@ -65,6 +66,7 @@ import to.bitkit.env.Env import to.bitkit.ext.getSatsPerVByteFor import to.bitkit.ext.nowMillis import to.bitkit.ext.nowTimestamp +import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.toPeerDetailsList import to.bitkit.ext.totalNextOutboundHtlcLimitSats import to.bitkit.models.ALL_ADDRESS_TYPE_STRINGS @@ -125,8 +127,9 @@ class LightningRepo @Inject constructor( private val _lightningState = MutableStateFlow(LightningState()) val lightningState = _lightningState.asStateFlow() - private val _nodeEvents = MutableSharedFlow(extraBufferCapacity = 64) - val nodeEvents = _nodeEvents.asSharedFlow() + private val _nodeEventUpdates = MutableSharedFlow(extraBufferCapacity = 64) + val nodeEventUpdates = _nodeEventUpdates.asSharedFlow() + val nodeEvents = nodeEventUpdates.map { it.event } private val scope = appScope(bgDispatcher, TAG) @@ -439,10 +442,87 @@ class LightningRepo @Inject constructor( private suspend fun onEvent(event: Event) { handleLdkEvent(event) recordProbeOutcome(event) + val settledReceiveInvoice: SettledReceiveInvoice? + val settledReceiveAddress: SettledReceiveAddress? + when (event) { + is Event.PaymentReceived -> { + settledReceiveInvoice = invalidateSettledReceiveInvoice(event) + settledReceiveAddress = null + val paymentId = event.paymentId ?: event.paymentHash + runSuspendCatching { coreService.activity.handlePaymentEvent(paymentId) } + .onFailure { Logger.error("Failed to record received payment '$paymentId'", it, context = TAG) } + } + + is Event.OnchainTransactionReceived -> { + settledReceiveInvoice = null + settledReceiveAddress = invalidateSettledReceiveAddress(event) + } + + else -> { + settledReceiveInvoice = null + settledReceiveAddress = null + } + } _eventHandlers.toList().forEach { runCatching { it.invoke(event) } } - _nodeEvents.emit(event) + _nodeEventUpdates.emit( + NodeEventUpdate( + event = event, + settledReceiveInvoice = settledReceiveInvoice, + settledReceiveAddress = settledReceiveAddress, + ), + ) + } + + private suspend fun invalidateSettledReceiveInvoice(event: Event.PaymentReceived): SettledReceiveInvoice? { + val cachedReceive = runSuspendCatching { + cacheStore.data.first() + }.onFailure { + Logger.error("Failed to read cached receive invoice", it, context = TAG) + }.getOrNull() ?: return null + val cachedBolt11 = cachedReceive.bolt11 + if (cachedBolt11.isEmpty()) return null + + val cachedPaymentHash = cachedReceive.bolt11PaymentHash.takeIf { it.isNotEmpty() } ?: runCatching { + Bolt11Invoice.fromStr(cachedBolt11).paymentHash() + }.onFailure { + Logger.error("Failed to parse cached receive invoice", it, context = TAG) + }.getOrNull() + if (cachedPaymentHash != event.paymentHash) return null + + val invalidated = runSuspendCatching { + cacheStore.invalidateReceiveLightningInvoice(expectedBolt11 = cachedBolt11) + }.onFailure { + Logger.error("Failed to invalidate settled receive invoice", it, context = TAG) + }.getOrDefault(false) + + return if (invalidated) { + SettledReceiveInvoice( + bolt11 = cachedBolt11, + ) + } else { + null + } + } + + private suspend fun invalidateSettledReceiveAddress( + event: Event.OnchainTransactionReceived, + ): SettledReceiveAddress? { + val cachedAddress = runSuspendCatching { + cacheStore.data.first().onchainAddress + }.onFailure { + Logger.error("Failed to read cached receive address", it, context = TAG) + }.getOrNull()?.takeIf { it.isNotEmpty() } ?: return null + if (event.details.outputs.none { it.scriptpubkeyAddress == cachedAddress }) return null + + val invalidated = runSuspendCatching { + cacheStore.invalidateReceiveOnchainAddress(expectedAddress = cachedAddress) + }.onFailure { + Logger.error("Failed to invalidate settled receive address", it, context = TAG) + }.getOrDefault(false) + + return if (invalidated) SettledReceiveAddress(cachedAddress) else null } fun setRecoveryMode(enabled: Boolean) = _isRecoveryMode.update { enabled } @@ -1743,6 +1823,21 @@ class NodeRunTimeoutError(opName: String) : AppError("Timeout waiting for node t class GetPaymentsError : AppError("It wasn't possible get the payments") class SyncUnhealthyError : AppError("Wallet sync failed before send") class LnurlPayInvoiceMismatchError : AppError("The invoice did not match the requested payment. Payment cancelled.") + +data class NodeEventUpdate( + val event: Event, + val settledReceiveInvoice: SettledReceiveInvoice? = null, + val settledReceiveAddress: SettledReceiveAddress? = null, +) + +data class SettledReceiveInvoice( + val bolt11: String, +) + +data class SettledReceiveAddress( + val address: String, +) + sealed class ProbeError(message: String) : AppError(message) { class NoProbeHandles : ProbeError("No probe handles returned") class TimedOut : ProbeError("Probe timed out") diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index d129a13295..96edad2fb3 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.lightningdevkit.ldknode.Bolt11Invoice import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.WordCount import to.bitkit.async.appScope @@ -82,9 +83,13 @@ class WalletRepo @Inject constructor( init { repoScope.launch { - lightningRepo.nodeEvents.collect { event -> + lightningRepo.nodeEventUpdates.collect { update -> if (!walletExists()) return@collect - refreshBip21ForEvent(event) + refreshBip21ForEvent( + event = update.event, + settledReceiveInvoice = update.settledReceiveInvoice, + settledReceiveAddress = update.settledReceiveAddress, + ) } } repoScope.launch { @@ -308,7 +313,11 @@ class WalletRepo @Inject constructor( eventSyncJob = null } - suspend fun refreshBip21ForEvent(event: Event) = withContext(bgDispatcher) { + suspend fun refreshBip21ForEvent( + event: Event, + settledReceiveInvoice: SettledReceiveInvoice? = null, + settledReceiveAddress: SettledReceiveAddress? = null, + ) = withContext(bgDispatcher) { when (event) { is Event.ChannelReady -> { // Only refresh bolt11 if we can now receive on lightning @@ -333,13 +342,25 @@ class WalletRepo @Inject constructor( } } - is Event.PaymentReceived, is Event.OnchainTransactionReceived -> { - // Check if onchain address was used, generate new one if needed - Logger.debug("refreshBip21ForEvent: $event", context = TAG) - refreshAddressIfNeeded() + is Event.PaymentReceived if settledReceiveInvoice != null -> { + val settledBolt11 = settledReceiveInvoice.bolt11 + if (!invalidateSettledReceiveInvoice(settledBolt11)) return@withContext + Logger.debug("Refreshing BIP21 for event '$event'", context = TAG) updateBip21Url() + refreshAddressIfNeeded() } + is Event.PaymentReceived -> Unit + + is Event.OnchainTransactionReceived if settledReceiveAddress != null -> { + val settledAddress = settledReceiveAddress.address + if (!invalidateSettledReceiveAddress(settledAddress)) return@withContext + Logger.debug("Refreshing BIP21 for event '$event'", context = TAG) + refreshReusableReceiveAddress() + } + + is Event.OnchainTransactionReceived -> Unit + else -> Unit } } @@ -348,6 +369,22 @@ class WalletRepo @Inject constructor( refreshReusableReceiveAddress() } + private fun invalidateSettledReceiveInvoice(settledBolt11: String): Boolean { + while (true) { + val state = _walletState.value + if (state.bolt11 != settledBolt11) return false + if (_walletState.compareAndSet(state, state.copy(bolt11 = "", bip21 = ""))) return true + } + } + + private fun invalidateSettledReceiveAddress(settledAddress: String): Boolean { + while (true) { + val state = _walletState.value + if (state.onchainAddress != settledAddress) return false + if (_walletState.compareAndSet(state, state.copy(onchainAddress = "", bip21 = ""))) return true + } + } + suspend fun refreshReusableReceiveAddress(): Result = withContext(bgDispatcher) { runSuspendCatching { refreshReusableReceiveAddressOrThrow() @@ -553,7 +590,14 @@ class WalletRepo @Inject constructor( fun getBolt11(): String = _walletState.value.bolt11 suspend fun setBolt11(bolt11: String) { - runCatching { cacheStore.saveBolt11(bolt11) } + val paymentHash = if (bolt11.isEmpty()) { + "" + } else { + runCatching { Bolt11Invoice.fromStr(bolt11).paymentHash() } + .onFailure { Logger.error("Failed to parse receive invoice", it, context = TAG) } + .getOrDefault("") + } + runSuspendCatching { cacheStore.saveBolt11(bolt11, paymentHash) } _walletState.update { it.copy(bolt11 = bolt11) } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 3449a58acf..abe1a655f5 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -130,6 +130,7 @@ import to.bitkit.repositories.HealthRepo import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LnurlPayInvoiceMismatchError +import to.bitkit.repositories.NodeEventUpdate import to.bitkit.repositories.PaymentPendingException import to.bitkit.repositories.PendingPaymentNotification import to.bitkit.repositories.PendingPaymentRepo @@ -263,6 +264,13 @@ class AppViewModel @Inject constructor( private val _currentSheet: MutableStateFlow = MutableStateFlow(null) val currentSheet = _currentSheet.asStateFlow() private var queuedPairingCodeRequestId: Long? = null + private var receiveSheetContext: ReceiveSheetContext? = null + + private data class ReceiveSheetContext( + val sheet: Sheet.Receive, + val bolt11: String, + val onchainAddress: String, + ) private val processedPaymentsLock = Any() private val processedPayments = mutableSetOf() @@ -383,6 +391,7 @@ class AppViewModel @Inject constructor( } } } + observeReceiveSheetInvoice() observeLdkNodeEvents() observeLightningUsableChannels() observePublicPaykitEndpoints() @@ -420,7 +429,23 @@ class AppViewModel @Inject constructor( private fun observeLdkNodeEvents() { viewModelScope.launch { - lightningRepo.nodeEvents.collect { handleLdkEvent(it) } + lightningRepo.nodeEventUpdates.collect { handleLdkEvent(it) } + } + } + + private fun observeReceiveSheetInvoice() { + viewModelScope.launch { + walletRepo.walletState + .map { it.bolt11 } + .distinctUntilChanged() + .collect { bolt11 -> + if (bolt11.isEmpty()) return@collect + val receiveSheet = _currentSheet.value as? Sheet.Receive ?: return@collect + receiveSheetContext = receiveSheetContext + ?.takeIf { it.sheet === receiveSheet } + ?.copy(bolt11 = bolt11) + ?: ReceiveSheetContext(receiveSheet, bolt11, walletRepo.getOnchainAddress()) + } } } @@ -595,10 +620,21 @@ class AppViewModel @Inject constructor( } @Suppress("CyclomaticComplexMethod") - private fun handleLdkEvent(event: Event) { + private fun handleLdkEvent(update: NodeEventUpdate) { + val event = update.event if (!walletRepo.walletExists()) return Logger.debug("LDK-node event received in $TAG: ${jsonLogOf(event)}", context = TAG) + val receiveSheetToClose = receiveSheetContext?.takeIf { context -> + val matchesSettledRequest = when (event) { + is Event.PaymentReceived -> update.settledReceiveInvoice?.bolt11 == context.bolt11 + is Event.OnchainTransactionReceived -> update.settledReceiveAddress?.address == context.onchainAddress + else -> false + } + matchesSettledRequest && + _currentSheet.value === context.sheet + } + viewModelScope.launch { runCatching { when (event) { @@ -608,13 +644,13 @@ class AppViewModel @Inject constructor( is Event.ChannelReady -> handleChannelReady(event) is Event.OnchainTransactionConfirmed -> handleOnchainTransactionConfirmed(event) is Event.OnchainTransactionEvicted -> handleOnchainTransactionEvicted(event) - is Event.OnchainTransactionReceived -> handleOnchainTransactionReceived(event) + is Event.OnchainTransactionReceived -> handleOnchainTransactionReceived(event, receiveSheetToClose) is Event.OnchainTransactionReorged -> handleOnchainTransactionReorged(event) is Event.OnchainTransactionReplaced -> handleOnchainTransactionReplaced(event) is Event.PaymentClaimable -> Unit is Event.PaymentFailed -> handlePaymentFailed(event) is Event.PaymentForwarded -> Unit - is Event.PaymentReceived -> handlePaymentReceived(event) + is Event.PaymentReceived -> handlePaymentReceived(event, receiveSheetToClose) is Event.PaymentSuccessful -> handlePaymentSuccessful(event) is Event.ProbeFailed -> Unit is Event.ProbeSuccessful -> Unit @@ -865,7 +901,11 @@ class AppViewModel @Inject constructor( notifyTransactionRemoved(event) } - private suspend fun handleOnchainTransactionReceived(event: Event.OnchainTransactionReceived) { + private suspend fun handleOnchainTransactionReceived( + event: Event.OnchainTransactionReceived, + receiveSheetToClose: ReceiveSheetContext?, + ) { + closeSettledReceiveSheet(receiveSheetToClose) val addresses = event.details.outputs.mapNotNull { it.scriptpubkeyAddress } val contactPublicKey = privatePaykitRepo.contactPublicKeyForPrivateOnchainAddresses(addresses) notifyPaymentReceived(event) @@ -934,9 +974,13 @@ class AppViewModel @Inject constructor( return true } - private suspend fun handlePaymentReceived(event: Event.PaymentReceived) { + private suspend fun handlePaymentReceived( + event: Event.PaymentReceived, + receiveSheetToClose: ReceiveSheetContext?, + ) { event.paymentHash.let { paymentHash -> - activityRepo.handlePaymentEvent(paymentHash) + closeSettledReceiveSheet(receiveSheetToClose) + activityRepo.notifyPaymentActivityChanged() privatePaykitRepo.contactPublicKeyForPrivateInvoicePaymentHash(paymentHash)?.let { publicKey -> activityRepo.setContact( contactPublicKey = publicKey, @@ -964,6 +1008,12 @@ class AppViewModel @Inject constructor( notifyPaymentReceived(event) } + private fun closeSettledReceiveSheet(context: ReceiveSheetContext?) { + if (context == null) return + if (receiveSheetContext !== context || _currentSheet.value !== context.sheet) return + hideSheet() + } + private suspend fun handlePaymentSuccessful(event: Event.PaymentSuccessful) { event.paymentHash.let { paymentHash -> activityRepo.handlePaymentEvent(paymentHash) @@ -1014,7 +1064,7 @@ class AppViewModel @Inject constructor( val command = NotifyPaymentReceived.Command.from(event) ?: return val result = notifyPaymentReceivedHandler(command).getOrNull() if (result !is NotifyPaymentReceived.Result.ShowSheet) return - showTransactionSheet(result.sheet) + notifyPaymentReceivedHandler.present(command) { showTransactionSheet(result.sheet) } } private fun notifyTransactionUnconfirmed() = toast( @@ -2849,16 +2899,25 @@ class AppViewModel @Inject constructor( fun showSheet(sheetType: Sheet) { viewModelScope.launch { + receiveSheetContext = null _currentSheet.value?.let { _currentSheet.update { null } delay(SCREEN_TRANSITION_DELAY) } + receiveSheetContext = (sheetType as? Sheet.Receive)?.let { receiveSheet -> + ReceiveSheetContext( + sheet = receiveSheet, + bolt11 = walletRepo.getBolt11(), + onchainAddress = walletRepo.getOnchainAddress(), + ) + } _currentSheet.update { sheetType } } } fun hideSheet() { scanResultHandler = null + receiveSheetContext = null when { currentSheet.value is Sheet.TimedSheet -> { // Only dismiss if manager still has a sheet (user initiated) diff --git a/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt index 27f09f40d2..8ad9f6a731 100644 --- a/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt +++ b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt @@ -154,6 +154,15 @@ class LightningNodeServiceTest : BaseUnitTest() { ) whenever(notifyPaymentReceivedHandler.invoke(any())) .thenReturn(Result.success(NotifyPaymentReceived.Result.ShowNotification(sheet, notification))) + whenever { notifyPaymentReceivedHandler.present(any(), any(), any()) }.thenAnswer { + val canPresent = it.getArgument<() -> Boolean>(1) + if (!canPresent()) { + false + } else { + it.getArgument<() -> Unit>(2).invoke() + true + } + } // Default: not a CJIT channel ready unless a test overrides it whenever(notifyChannelReadyHandler.invoke(any())) @@ -306,10 +315,11 @@ class LightningNodeServiceTest : BaseUnitTest() { sats = 100L, ) verify(cacheStore).setBackgroundReceive(expected) + verify(notifyPaymentReceivedHandler).present(any(), any(), any()) } @Test - fun `payment received in foreground does nothing`() = test { + fun `payment received in foreground is left for the UI`() = test { // Simulate foreground by setting App.currentActivity.value via lifecycle callback val mockActivity: Activity = mock() App.currentActivity?.onActivityStarted(mockActivity) @@ -337,6 +347,85 @@ class LightningNodeServiceTest : BaseUnitTest() { assertNull(paymentNotification, "Payment notification should NOT be present in foreground") verify(cacheStore, never()).setBackgroundReceive(any()) + verify(notifyPaymentReceivedHandler).invoke(any()) + verify(notifyPaymentReceivedHandler).present(any(), any(), any()) + } + + @Test + fun `payment received while app foregrounds is left for the UI`() = test { + val mockActivity: Activity = mock() + whenever(notifyPaymentReceivedHandler.invoke(any())).doSuspendableAnswer { + App.currentActivity?.onActivityStarted(mockActivity) + val sheet = NewTransactionSheetDetails( + type = NewTransactionSheetType.LIGHTNING, + direction = NewTransactionSheetDirection.RECEIVED, + paymentHashOrTxId = "test_hash", + sats = 100L, + ) + val notification = NotificationDetails( + title = context.getString(R.string.notification__received__title), + body = "Received ₿ 100 ($0.10)", + ) + Result.success(NotifyPaymentReceived.Result.ShowNotification(sheet, notification)) + } + startService() + testScheduler.advanceUntilIdle() + + capturedHandler?.invoke( + Event.PaymentReceived( + paymentId = "payment_id", + paymentHash = "test_hash", + amountMsat = 100000u, + customRecords = emptyList(), + ), + ) + testScheduler.advanceUntilIdle() + + val notification = Shadows.shadowOf(context.notificationManager).allNotifications.find { + it.extras.getString(Notification.EXTRA_TITLE) == context.getString(R.string.notification__received__title) + } + assertNull(notification) + verify(notifyPaymentReceivedHandler).present(any(), any(), any()) + verify(cacheStore, never()).setBackgroundReceive(any()) + } + + @Test + fun `payment received while app backgrounds is presented by the service`() = test { + val mockActivity: Activity = mock() + App.currentActivity?.onActivityStarted(mockActivity) + whenever(notifyPaymentReceivedHandler.invoke(any())).doSuspendableAnswer { + App.currentActivity?.onActivityStopped(mockActivity) + val sheet = NewTransactionSheetDetails( + type = NewTransactionSheetType.LIGHTNING, + direction = NewTransactionSheetDirection.RECEIVED, + paymentHashOrTxId = "test_hash", + sats = 100L, + ) + val notification = NotificationDetails( + title = context.getString(R.string.notification__received__title), + body = "Received ₿ 100 ($0.10)", + ) + Result.success(NotifyPaymentReceived.Result.ShowNotification(sheet, notification)) + } + startService() + testScheduler.advanceUntilIdle() + + capturedHandler?.invoke( + Event.PaymentReceived( + paymentId = "payment_id", + paymentHash = "test_hash", + amountMsat = 100000u, + customRecords = emptyList(), + ), + ) + testScheduler.advanceUntilIdle() + + val notification = Shadows.shadowOf(context.notificationManager).allNotifications.find { + it.extras.getString(Notification.EXTRA_TITLE) == context.getString(R.string.notification__received__title) + } + assertNotNull(notification) + verify(notifyPaymentReceivedHandler).present(any(), any(), any()) + verify(cacheStore).setBackgroundReceive(any()) } @Test diff --git a/app/src/test/java/to/bitkit/data/AppCacheDataTest.kt b/app/src/test/java/to/bitkit/data/AppCacheDataTest.kt new file mode 100644 index 0000000000..ed4671630d --- /dev/null +++ b/app/src/test/java/to/bitkit/data/AppCacheDataTest.kt @@ -0,0 +1,50 @@ +package to.bitkit.data + +import org.junit.Test +import to.bitkit.data.serializers.AppCacheSerializer +import to.bitkit.models.BalanceState +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals + +class AppCacheDataTest : BaseUnitTest() { + companion object { + private const val LEGACY_BOLT11 = + "lnbc1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs" + + "pp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypq" + + "dpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8qun0dfjkxaq" + + "9qrsgq357wnc5r2ueh7ck6q93dj32dlqnls087fxdwk8qakdyafkq3yap9us6v52" + + "vjjsrvywa6rt52cm9r9zqt8r2t7mlcwspyetp5h2tztugp9lfyql" + } + + @Test + fun `AppCacheSerializer reads a legacy receive invoice without a stored payment hash`() = test { + val cachedReceive = AppCacheSerializer.readFrom( + """{"bolt11":"$LEGACY_BOLT11","bip21":"bitcoin:bc1qtest?lightning=$LEGACY_BOLT11"}""" + .byteInputStream(), + ) + + assertEquals(LEGACY_BOLT11, cachedReceive.bolt11) + assertEquals("", cachedReceive.bolt11PaymentHash) + } + + @Test + fun `invalidateReceiveLightningInvoice clears the paid request and preserves other cache data`() { + val cache = AppCacheData( + onchainAddress = "bc1qtest", + bolt11 = "paidInvoice", + bolt11PaymentHash = "paymentHash", + bip21 = "bitcoin:bc1qtest?lightning=paidInvoice", + balance = BalanceState(totalOnchainSats = 42uL), + paidOrders = mapOf("order" to "txid"), + ) + + val invalidated = cache.invalidateReceiveLightningInvoice() + + assertEquals("", invalidated.bolt11) + assertEquals("", invalidated.bolt11PaymentHash) + assertEquals("", invalidated.bip21) + assertEquals(cache.onchainAddress, invalidated.onchainAddress) + assertEquals(cache.balance, invalidated.balance) + assertEquals(cache.paidOrders, invalidated.paidOrders) + } +} diff --git a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt new file mode 100644 index 0000000000..f21e15ea02 --- /dev/null +++ b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt @@ -0,0 +1,83 @@ +package to.bitkit.data + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@Config(application = Application::class, sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class CacheStoreTest : BaseUnitTest() { + private val context = ApplicationProvider.getApplicationContext() + private val sut = CacheStore(context) + + @Before + fun setUp() = runBlocking { sut.reset() } + + @After + fun tearDown() = runBlocking { sut.reset() } + + @Test + fun `invalidateReceiveLightningInvoice preserves a replacement invoice`() = test { + val replacement = AppCacheData( + onchainAddress = "bc1qtest", + bolt11 = "replacementInvoice", + bolt11PaymentHash = "replacementHash", + bip21 = "bitcoin:bc1qtest?lightning=replacementInvoice", + ) + sut.update { replacement } + + val invalidated = sut.invalidateReceiveLightningInvoice(expectedBolt11 = "settledInvoice") + + assertFalse(invalidated) + assertEquals(replacement, sut.data.first()) + } + + @Test + fun `invalidateReceiveOnchainAddress clears the matching address`() = test { + sut.update { + AppCacheData( + onchainAddress = "settledAddress", + bolt11 = "activeInvoice", + bolt11PaymentHash = "activeHash", + bip21 = "bitcoin:settledAddress?lightning=activeInvoice", + ) + } + + val invalidated = sut.invalidateReceiveOnchainAddress(expectedAddress = "settledAddress") + val data = sut.data.first() + + assertTrue(invalidated) + assertEquals("", data.onchainAddress) + assertEquals("", data.bip21) + assertEquals("activeInvoice", data.bolt11) + assertEquals("activeHash", data.bolt11PaymentHash) + } + + @Test + fun `invalidateReceiveOnchainAddress preserves a replacement address`() = test { + val replacement = AppCacheData( + onchainAddress = "replacementAddress", + bolt11 = "activeInvoice", + bolt11PaymentHash = "activeHash", + bip21 = "bitcoin:replacementAddress?lightning=activeInvoice", + ) + sut.update { replacement } + + val invalidated = sut.invalidateReceiveOnchainAddress(expectedAddress = "settledAddress") + + assertFalse(invalidated) + assertEquals(replacement, sut.data.first()) + } +} diff --git a/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt b/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt index 1c935765f6..b4d2eb069c 100644 --- a/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt +++ b/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt @@ -25,6 +25,7 @@ import to.bitkit.repositories.CurrencyRepo import to.bitkit.test.BaseUnitTest import java.math.BigDecimal import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -85,6 +86,12 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { assertEquals(NewTransactionSheetDirection.RECEIVED, paymentResult.sheet.direction) assertEquals("hash123", paymentResult.sheet.paymentHashOrTxId) assertEquals(1000L, paymentResult.sheet.sats) + verify(activityRepo, never()).markActivityAsSeen(any()) + + val claimed = sut.claimPresentation(command) + + assertTrue(claimed) + sut.recordPresentation(command) verify(activityRepo).markActivityAsSeen("paymentId123") } @@ -110,6 +117,13 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { assertEquals("hash123", paymentResult.sheet.paymentHashOrTxId) assertNotNull(paymentResult.notification) assertEquals("Payment Received", paymentResult.notification.title) + verify(activityRepo, never()).markActivityAsSeen(any()) + + val claimed = sut.claimPresentation(command) + + assertTrue(claimed) + sut.recordPresentation(command) + verify(activityRepo).markActivityAsSeen("paymentId123") } @Test @@ -133,6 +147,12 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { assertEquals(NewTransactionSheetDirection.RECEIVED, paymentResult.sheet.direction) assertEquals("txid456", paymentResult.sheet.paymentHashOrTxId) assertEquals(5000L, paymentResult.sheet.sats) + verify(activityRepo, never()).markOnchainActivityAsSeen(any()) + + val claimed = sut.claimPresentation(command) + + assertTrue(claimed) + sut.recordPresentation(command) verify(activityRepo).markOnchainActivityAsSeen("txid456") } @@ -168,6 +188,8 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { val command = NotifyPaymentReceived.Command.Onchain(event = event) sut(command) + sut.claimPresentation(command) + sut.recordPresentation(command) inOrder(activityRepo) { verify(activityRepo).handleOnchainTransactionReceived("txid789", details) @@ -243,4 +265,63 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { val paymentResult = result.getOrThrow() assertTrue(paymentResult is NotifyPaymentReceived.Result.Skip) } + + @Test + fun `recordPresentation keeps the claim when marking as seen fails`() = test { + val event = mock { + on { amountMsat } doReturn 1000000uL + on { paymentHash } doReturn "hash123" + on { paymentId } doReturn "paymentId123" + } + whenever(activityRepo.markActivityAsSeen("paymentId123")) + .thenThrow(IllegalStateException("activity store unavailable")) + val command = NotifyPaymentReceived.Command.Lightning(event = event) + + assertTrue(sut.claimPresentation(command)) + sut.recordPresentation(command) + assertFalse(sut.claimPresentation(command)) + } + + @Test + fun `present retains the claim after marking as seen succeeds`() = test { + val event = mock { + on { paymentId } doReturn "paymentId123" + } + val command = NotifyPaymentReceived.Command.Lightning(event = event) + var presentationCount = 0 + + val presented = sut.present(command) { presentationCount += 1 } + + assertTrue(presented) + assertEquals(1, presentationCount) + verify(activityRepo).markActivityAsSeen("paymentId123") + assertFalse(sut.claimPresentation(command)) + } + + @Test + fun `invoke returns Skip after the payment presentation is claimed`() = test { + val event = mock { + on { amountMsat } doReturn 1000000uL + on { paymentHash } doReturn "hash123" + on { paymentId } doReturn "paymentId123" + } + val command = NotifyPaymentReceived.Command.Lightning(event = event) + assertTrue(sut.claimPresentation(command)) + + val result = sut(command) + + assertTrue(result.getOrThrow() is NotifyPaymentReceived.Result.Skip) + verify(activityRepo, never()).isActivitySeen(any()) + } + + @Test + fun `claimPresentation leaves the payment available when presentation is not allowed`() = test { + val event = mock { + on { paymentId } doReturn "paymentId123" + } + val command = NotifyPaymentReceived.Command.Lightning(event = event) + + assertFalse(sut.claimPresentation(command) { false }) + assertTrue(sut.claimPresentation(command)) + } } diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 5d721ce3fa..920974cf81 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -11,11 +11,13 @@ import com.synonym.bitkitcore.LnurlException import com.synonym.bitkitcore.LnurlPayData import com.synonym.bitkitcore.NetworkType import com.synonym.bitkitcore.Scanner +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking import org.junit.Before @@ -28,10 +30,13 @@ import org.lightningdevkit.ldknode.NodeStatus import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PeerDetails import org.lightningdevkit.ldknode.SpendableUtxo +import org.lightningdevkit.ldknode.TransactionDetails +import org.lightningdevkit.ldknode.TxOutput import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argThat import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.inOrder import org.mockito.kotlin.isNull @@ -42,6 +47,7 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever +import to.bitkit.data.AppCacheData import to.bitkit.data.CacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore @@ -53,6 +59,7 @@ import to.bitkit.models.CoinSelectionPreference import to.bitkit.models.NodeLifecycleState import to.bitkit.models.OpenChannelResult import to.bitkit.models.TransactionSpeed +import to.bitkit.services.ActivityService import to.bitkit.services.BlocktankService import to.bitkit.services.CoreService import to.bitkit.services.LightningService @@ -102,6 +109,7 @@ class LightningRepoTest : BaseUnitTest() { whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())).thenReturn(Unit) whenever(lightningService.start(anyOrNull(), any())).thenReturn(Unit) whenever(coreService.isGeoBlocked()).thenReturn(false) + whenever(cacheStore.data).thenReturn(flowOf(AppCacheData())) whenever(connectivityRepo.isOnline).thenReturn(MutableStateFlow(ConnectivityState.CONNECTED)) whenever(settingsStore.data).thenReturn(flowOf(SettingsData())) whenever(lightningService.aresRequiredPeersInNetworkGraph()).thenReturn(true) @@ -189,6 +197,146 @@ class LightningRepoTest : BaseUnitTest() { } } + @Test + fun `PaymentReceived invalidates the cached invoice without decoding during event handling`() = test { + val bolt11 = "lnbc1" + val activityService = mock() + val processingStarted = CompletableDeferred() + val resumeProcessing = CompletableDeferred() + whenever(cacheStore.data).thenReturn( + flowOf(AppCacheData(bolt11 = bolt11, bolt11PaymentHash = "010203")) + ) + whenever(coreService.decode(bolt11)).thenThrow(IllegalStateException("decoder unavailable")) + whenever(cacheStore.invalidateReceiveLightningInvoice(bolt11)).thenReturn(true) + whenever(coreService.activity).thenReturn(activityService) + whenever(activityService.handlePaymentEvent("payment-id")).doSuspendableAnswer { + processingStarted.complete(Unit) + resumeProcessing.await() + } + val eventHandler = startNodeAndCaptureEvents() + val event = Event.PaymentReceived( + paymentId = "payment-id", + paymentHash = "010203", + amountMsat = 1_000uL, + customRecords = emptyList(), + ) + val publishedUpdate = async { sut.nodeEventUpdates.first() } + + val handling = async { eventHandler(event) } + processingStarted.await() + + verify(cacheStore).invalidateReceiveLightningInvoice(bolt11) + verify(coreService, never()).decode(bolt11) + assertFalse(publishedUpdate.isCompleted) + + resumeProcessing.complete(Unit) + handling.await() + val update = publishedUpdate.await() + assertEquals(event, update.event) + assertEquals( + SettledReceiveInvoice(bolt11 = bolt11), + update.settledReceiveInvoice, + ) + } + + @Test + fun `PaymentReceived preserves a cached invoice with a different payment hash`() = test { + val bolt11 = "lnbc1" + val activityService = mock() + whenever(cacheStore.data).thenReturn( + flowOf(AppCacheData(bolt11 = bolt11, bolt11PaymentHash = "010203")) + ) + whenever(coreService.activity).thenReturn(activityService) + val eventHandler = startNodeAndCaptureEvents() + val event = Event.PaymentReceived( + paymentId = "payment-id", + paymentHash = "different-payment-hash", + amountMsat = 1_000uL, + customRecords = emptyList(), + ) + + val publishedUpdate = async { sut.nodeEventUpdates.first() } + + eventHandler(event) + + verify(cacheStore, never()).invalidateReceiveLightningInvoice(any()) + val update = publishedUpdate.await() + assertEquals(event, update.event) + assertNull(update.settledReceiveInvoice) + verify(activityService).handlePaymentEvent("payment-id") + } + + @Test + fun `PaymentReceived does not publish settlement when cache invalidation fails`() = test { + val bolt11 = "lnbc1" + val activityService = mock() + whenever(cacheStore.data).thenReturn( + flowOf(AppCacheData(bolt11 = bolt11, bolt11PaymentHash = "010203")) + ) + whenever( + cacheStore.invalidateReceiveLightningInvoice(bolt11) + ).thenThrow( + IllegalStateException("disk unavailable") + ) + whenever(coreService.activity).thenReturn(activityService) + val eventHandler = startNodeAndCaptureEvents() + val event = Event.PaymentReceived( + paymentId = "payment-id", + paymentHash = "010203", + amountMsat = 1_000uL, + customRecords = emptyList(), + ) + val publishedUpdate = async { sut.nodeEventUpdates.first() } + + eventHandler(event) + + val update = publishedUpdate.await() + assertEquals(event, update.event) + assertNull(update.settledReceiveInvoice) + verify(activityService).handlePaymentEvent("payment-id") + } + + @Test + fun `OnchainTransactionReceived invalidates the matching cached receive address`() = test { + val address = "bcrt1qsettled" + val output = mock() + whenever(output.scriptpubkeyAddress).thenReturn(address) + val details = mock() + whenever(details.outputs).thenReturn(listOf(output)) + whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(onchainAddress = address))) + whenever(cacheStore.invalidateReceiveOnchainAddress(address)).thenReturn(true) + val eventHandler = startNodeAndCaptureEvents() + val event = Event.OnchainTransactionReceived(txid = "txid", details = details) + val publishedUpdate = async { sut.nodeEventUpdates.first() } + + eventHandler(event) + + val update = publishedUpdate.await() + assertEquals(event, update.event) + assertEquals(SettledReceiveAddress(address), update.settledReceiveAddress) + verify(cacheStore).invalidateReceiveOnchainAddress(address) + } + + @Test + fun `OnchainTransactionReceived preserves an unrelated cached receive address`() = test { + val cachedAddress = "bcrt1qcurrent" + val output = mock() + whenever(output.scriptpubkeyAddress).thenReturn("bcrt1qold") + val details = mock() + whenever(details.outputs).thenReturn(listOf(output)) + whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(onchainAddress = cachedAddress))) + val eventHandler = startNodeAndCaptureEvents() + val event = Event.OnchainTransactionReceived(txid = "txid", details = details) + val publishedUpdate = async { sut.nodeEventUpdates.first() } + + eventHandler(event) + + val update = publishedUpdate.await() + assertEquals(event, update.event) + assertNull(update.settledReceiveAddress) + verify(cacheStore, never()).invalidateReceiveOnchainAddress(any()) + } + @Test fun `stop should transition to stopped state`() = test { startNodeForTesting() diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index 5b464a4ba9..393d623f62 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -15,6 +15,7 @@ import org.junit.Test import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.Network +import org.lightningdevkit.ldknode.TransactionDetails import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doReturn @@ -62,6 +63,7 @@ class WalletRepoTest : BaseUnitTest() { const val ADDRESS = "bc1qTest" const val ADDRESS_NEW = "newAddress" const val INVOICE = "testInvoice" + const val INVOICE_REPLACEMENT = "replacementInvoice" const val SATS = 1000uL val error = RuntimeException("Test Error") val channels = listOf( @@ -89,7 +91,7 @@ class WalletRepoTest : BaseUnitTest() { whenever { cacheStore.update(any()) }.thenReturn(Unit) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) whenever(hwWalletRepo.wallets).thenReturn(MutableStateFlow(persistentListOf())) - whenever(lightningRepo.nodeEvents).thenReturn(MutableSharedFlow()) + whenever(lightningRepo.nodeEventUpdates).thenReturn(MutableSharedFlow()) whenever(lightningRepo.listSpendableOutputs()).thenReturn(Result.success(emptyList())) whenever(lightningRepo.calculateTotalFee(any(), any(), any(), any(), anyOrNull())) .thenReturn(Result.success(SATS)) @@ -392,7 +394,7 @@ class WalletRepoTest : BaseUnitTest() { sut.setBolt11(INVOICE) assertEquals(INVOICE, sut.walletState.value.bolt11) - verify(cacheStore).saveBolt11(INVOICE) + verify(cacheStore).saveBolt11(INVOICE, "") } @Test @@ -631,7 +633,7 @@ class WalletRepoTest : BaseUnitTest() { @Test fun `refreshBip21ForEvent PaymentReceived should refresh address if used`() = test { - whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(onchainAddress = ADDRESS))) + whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(onchainAddress = ADDRESS, bolt11 = INVOICE))) whenever(coreService.isAddressUsed(any())).thenReturn(true) sut = createSut() sut.loadFromCache() @@ -641,8 +643,9 @@ class WalletRepoTest : BaseUnitTest() { paymentId = "testPaymentId", paymentHash = "testPaymentHash", amountMsat = 1000uL, - customRecords = emptyList() - ) + customRecords = emptyList(), + ), + settledReceiveInvoice = SettledReceiveInvoice(INVOICE), ) verify(privatePaykitAddressReservationRepo).nextReusableReceiveAddress() @@ -650,7 +653,7 @@ class WalletRepoTest : BaseUnitTest() { @Test fun `refreshBip21ForEvent PaymentReceived should not refresh address if not used`() = test { - whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(onchainAddress = ADDRESS))) + whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(onchainAddress = ADDRESS, bolt11 = INVOICE))) whenever(coreService.isAddressUsed(any())).thenReturn(false) sut = createSut() sut.loadFromCache() @@ -660,11 +663,147 @@ class WalletRepoTest : BaseUnitTest() { paymentId = "testPaymentId", paymentHash = "testPaymentHash", amountMsat = 1000uL, - customRecords = emptyList() - ) + customRecords = emptyList(), + ), + settledReceiveInvoice = SettledReceiveInvoice(INVOICE), + ) + + verify(privatePaykitAddressReservationRepo, never()).nextReusableReceiveAddress() + } + + @Test + fun `refreshBip21ForEvent PaymentReceived should invalidate the settled invoice`() = test { + whenever(lightningRepo.canReceive()).thenReturn(true) + whenever(lightningRepo.createInvoice(anyOrNull(), any(), any())) + .thenReturn(Result.success(INVOICE_REPLACEMENT)) + sut.setOnchainAddress(ADDRESS) + sut.setBolt11(INVOICE) + sut.setBip21("bitcoin:$ADDRESS?lightning=$INVOICE") + + sut.refreshBip21ForEvent( + Event.PaymentReceived( + paymentId = "testPaymentId", + paymentHash = "testPaymentHash", + amountMsat = 1000uL, + customRecords = emptyList(), + ), + settledReceiveInvoice = SettledReceiveInvoice(INVOICE), + ) + + assertEquals("", sut.walletState.value.bolt11) + assertFalse(sut.walletState.value.bip21.contains(INVOICE)) + assertTrue(sut.walletState.value.bip21.contains(ADDRESS)) + verify(cacheStore, never()).invalidateReceiveLightningInvoice(any()) + verify(lightningRepo, never()).createInvoice(anyOrNull(), any(), any()) + verify(preActivityMetadataRepo, never()).addPreActivityMetadata(any()) + } + + @Test + fun `refreshBip21ForEvent PaymentReceived should preserve an unrelated receive invoice`() = test { + sut.setOnchainAddress(ADDRESS) + sut.setBolt11(INVOICE) + sut.setBip21("bitcoin:$ADDRESS?lightning=$INVOICE") + + sut.refreshBip21ForEvent( + Event.PaymentReceived( + paymentId = "testPaymentId", + paymentHash = "unrelatedPaymentHash", + amountMsat = 1000uL, + customRecords = emptyList(), + ), + ) + + assertEquals(INVOICE, sut.walletState.value.bolt11) + assertTrue(sut.walletState.value.bip21.contains(INVOICE)) + verify(cacheStore, never()).invalidateReceiveLightningInvoice(any()) + } + + @Test + fun `refreshBip21ForEvent PaymentReceived should preserve a replacement invoice`() = test { + sut.setOnchainAddress(ADDRESS) + sut.setBolt11(INVOICE_REPLACEMENT) + sut.setBip21("bitcoin:$ADDRESS?lightning=$INVOICE_REPLACEMENT") + + sut.refreshBip21ForEvent( + event = Event.PaymentReceived( + paymentId = "testPaymentId", + paymentHash = "testPaymentHash", + amountMsat = 1000uL, + customRecords = emptyList(), + ), + settledReceiveInvoice = SettledReceiveInvoice(INVOICE), + ) + + assertEquals(INVOICE_REPLACEMENT, sut.walletState.value.bolt11) + assertTrue(sut.walletState.value.bip21.contains(INVOICE_REPLACEMENT)) + verify(privatePaykitAddressReservationRepo, never()).nextReusableReceiveAddress() + } + + @Test + fun `refreshBip21ForEvent OnchainTransactionReceived rotates the settled address`() = test { + sut.setOnchainAddress(ADDRESS) + sut.setBolt11(INVOICE) + sut.setBip21("bitcoin:$ADDRESS?lightning=$INVOICE") + + sut.refreshBip21ForEvent( + event = Event.OnchainTransactionReceived( + txid = "txid", + details = mock(), + ), + settledReceiveAddress = SettledReceiveAddress(ADDRESS), + ) + + assertEquals(ADDRESS_NEW, sut.walletState.value.onchainAddress) + assertTrue(sut.walletState.value.bip21.contains(ADDRESS_NEW)) + assertTrue(sut.walletState.value.bip21.contains(INVOICE)) + verify(privatePaykitAddressReservationRepo).nextReusableReceiveAddress() + verify(coreService, never()).isAddressUsed(any()) + } + + @Test + fun `refreshBip21ForEvent OnchainTransactionReceived preserves a replacement address`() = test { + sut.setOnchainAddress(ADDRESS_NEW) + sut.setBolt11(INVOICE) + sut.setBip21("bitcoin:$ADDRESS_NEW?lightning=$INVOICE") + + sut.refreshBip21ForEvent( + event = Event.OnchainTransactionReceived( + txid = "txid", + details = mock(), + ), + settledReceiveAddress = SettledReceiveAddress(ADDRESS), ) + assertEquals(ADDRESS_NEW, sut.walletState.value.onchainAddress) + assertTrue(sut.walletState.value.bip21.contains(ADDRESS_NEW)) verify(privatePaykitAddressReservationRepo, never()).nextReusableReceiveAddress() + verify(coreService, never()).isAddressUsed(any()) + } + + @Test + fun `refreshBip21 should create a fresh invoice after PaymentReceived invalidates the old one`() = test { + whenever(lightningRepo.canReceive()).thenReturn(true) + whenever(lightningRepo.createInvoice(anyOrNull(), any(), any())) + .thenReturn(Result.success(INVOICE_REPLACEMENT)) + sut.setOnchainAddress(ADDRESS) + sut.setBolt11(INVOICE) + sut.setBip21("bitcoin:$ADDRESS?lightning=$INVOICE") + + sut.refreshBip21ForEvent( + Event.PaymentReceived( + paymentId = "testPaymentId", + paymentHash = "testPaymentHash", + amountMsat = 1000uL, + customRecords = emptyList(), + ), + settledReceiveInvoice = SettledReceiveInvoice(INVOICE), + ) + assertEquals("", sut.walletState.value.bolt11) + + sut.refreshBip21() + + assertEquals(INVOICE_REPLACEMENT, sut.walletState.value.bolt11) + assertTrue(sut.walletState.value.bip21.contains(INVOICE_REPLACEMENT)) } @Test diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 20067b502f..53d2352c01 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -1,5 +1,6 @@ package to.bitkit.viewmodels +import android.app.Activity import android.content.ClipData import android.content.ClipboardManager import android.content.Context @@ -12,35 +13,46 @@ import com.synonym.bitkitcore.LightningInvoice import com.synonym.bitkitcore.NetworkType import com.synonym.bitkitcore.Scanner import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.lightningdevkit.ldknode.Event +import org.lightningdevkit.ldknode.TransactionDetails import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.check import org.mockito.kotlin.clearInvocations +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import to.bitkit.App +import to.bitkit.CurrentActivity import to.bitkit.data.AppCacheData import to.bitkit.data.CacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.domain.commands.NotifyChannelReadyHandler +import to.bitkit.domain.commands.NotifyPaymentReceived import to.bitkit.domain.commands.NotifyPaymentReceivedHandler import to.bitkit.models.BalanceState import to.bitkit.models.HwWalletReceivedTx +import to.bitkit.models.NewTransactionSheetDetails +import to.bitkit.models.NewTransactionSheetDirection +import to.bitkit.models.NewTransactionSheetType import to.bitkit.models.PubkyProfile import to.bitkit.models.SamRockPaymentMethod import to.bitkit.models.SamRockSetupRequest @@ -57,6 +69,7 @@ import to.bitkit.repositories.HealthRepo import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState +import to.bitkit.repositories.NodeEventUpdate import to.bitkit.repositories.PaymentPendingException import to.bitkit.repositories.PendingPaymentRepo import to.bitkit.repositories.PendingPaymentResolution @@ -65,6 +78,8 @@ import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.repositories.SamRockRepo +import to.bitkit.repositories.SettledReceiveAddress +import to.bitkit.repositories.SettledReceiveInvoice import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo import to.bitkit.repositories.WalletState @@ -139,7 +154,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val settingsData = MutableStateFlow(SettingsData()) private val isPaykitEnabled = MutableStateFlow(false) private val walletState = MutableStateFlow(WalletState()) - private val nodeEvents = MutableSharedFlow() + private val nodeEventUpdates = MutableSharedFlow() private val pubkyPublicKey = MutableStateFlow(null) private val pubkyContacts = MutableStateFlow>(emptyList()) private val pubkyContactsLoadVersion = MutableStateFlow(0L) @@ -153,6 +168,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut = createViewModel() } + @After + fun tearDown() { + App.currentActivity = null + } + @Suppress("LongMethod") private fun stubRepositories() { whenever(context.getString(any())).thenReturn("") @@ -160,13 +180,16 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(connectivityRepo.isOnline).thenReturn(MutableStateFlow(ConnectivityState.CONNECTED)) whenever(healthRepo.healthState).thenReturn(MutableStateFlow(mock())) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) - whenever(lightningRepo.nodeEvents).thenReturn(nodeEvents) + whenever(lightningRepo.nodeEventUpdates).thenReturn(nodeEventUpdates) + whenever(lightningRepo.nodeEvents).thenReturn(nodeEventUpdates.map { it.event }) whenever(hwWalletRepo.receivedTxs).thenReturn(hwReceivedTxs) whenever(hwWalletRepo.needsPairingCode).thenReturn(needsPairingCode) whenever(hwWalletRepo.pairingCodeRequestId).thenReturn(pairingCodeRequestId) whenever(coreService.activity).thenReturn(activityService) whenever(walletRepo.balanceState).thenReturn(balanceState) whenever(walletRepo.walletState).thenReturn(walletState) + whenever(walletRepo.getBolt11()).thenAnswer { walletState.value.bolt11 } + whenever(walletRepo.getOnchainAddress()).thenAnswer { walletState.value.onchainAddress } whenever(walletRepo.walletExists()).thenReturn(true) whenever(backupRepo.isRestoring).thenReturn(MutableStateFlow(false)) stubSettingsStore() @@ -205,6 +228,21 @@ class AppViewModelSendFlowTest : BaseUnitTest() { .thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.contactPublicKeyForPrivateInvoicePaymentHash(any()) } .thenReturn(null) + whenever { publicPaykitRepo.refreshPublishedBolt11ForPayment(any()) } + .thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.handleReceivedPayment(any()) } + .thenReturn(Result.success(Unit)) + whenever { notifyPaymentReceivedHandler(any()) } + .thenReturn(Result.success(NotifyPaymentReceived.Result.Skip)) + whenever { notifyPaymentReceivedHandler.present(any(), any(), any()) }.thenAnswer { + val canPresent = it.getArgument<() -> Boolean>(1) + if (!canPresent()) { + false + } else { + it.getArgument<() -> Unit>(2).invoke() + true + } + } whenever { privatePaykitRepo.contactPublicKeyForPrivateOnchainAddresses(any>()) } .thenReturn(null) whenever { privatePaykitRepo.discardRemoteLightningEndpoints(any(), any()) } @@ -271,6 +309,20 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pubkyRepo = pubkyRepo, ) + private suspend fun emitNodeEvent( + event: Event, + settledReceiveInvoice: SettledReceiveInvoice? = null, + settledReceiveAddress: SettledReceiveAddress? = null, + ) { + nodeEventUpdates.emit( + NodeEventUpdate( + event = event, + settledReceiveInvoice = settledReceiveInvoice, + settledReceiveAddress = settledReceiveAddress, + ), + ) + } + @Test fun `onUsbDeviceAttached forwards to the hardware wallet repo`() = test { sut.onUsbDeviceAttached() @@ -879,7 +931,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() setPendingContactPaymentContext(paymentHash, contactKey) - nodeEvents.emit( + emitNodeEvent( Event.PaymentSuccessful( paymentId = "payment_id", paymentHash = paymentHash, @@ -901,7 +953,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() setPendingContactPaymentContext(paymentHash, "pubkycontact") - nodeEvents.emit( + emitNodeEvent( Event.PaymentFailed( paymentId = "payment_id", paymentHash = paymentHash, @@ -930,7 +982,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.showSheet(Sheet.Send()) advanceUntilIdle() - nodeEvents.emit( + emitNodeEvent( Event.PaymentFailed( paymentId = "payment_id", paymentHash = paymentHash, @@ -942,6 +994,299 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertNull(sut.currentSheet.value) } + @Test + fun `received lightning payment closes the active receive sheet after wallet invoice is cleared`() = test { + walletState.value = WalletState(bolt11 = "settled-invoice") + sut.showSheet(Sheet.Receive()) + advanceUntilIdle() + walletState.value = WalletState(bolt11 = "") + advanceUntilIdle() + + emitNodeEvent( + Event.PaymentReceived( + paymentId = "payment-id", + paymentHash = "payment-hash", + amountMsat = 1_000uL, + customRecords = emptyList(), + ), + settledReceiveInvoice = SettledReceiveInvoice("settled-invoice"), + ) + advanceUntilIdle() + + assertNull(sut.currentSheet.value) + verify(activityRepo).notifyPaymentActivityChanged() + verify(activityRepo, never()).handlePaymentEvent("payment-hash") + verify(notifyPaymentReceivedHandler).invoke(any()) + } + + @Test + fun `received unrelated lightning payment preserves the active receive sheet`() = test { + val sheet = Sheet.Receive() + sut.showSheet(sheet) + advanceUntilIdle() + + emitNodeEvent( + Event.PaymentReceived( + paymentId = "payment-id", + paymentHash = "unrelated-payment-hash", + amountMsat = 1_000uL, + customRecords = emptyList(), + ), + ) + advanceUntilIdle() + + assertEquals(sheet, sut.currentSheet.value) + verify(activityRepo).notifyPaymentActivityChanged() + verify(activityRepo, never()).handlePaymentEvent("unrelated-payment-hash") + } + + @Test + fun `received onchain payment closes the receive sheet for the settled address`() = test { + val address = "bcrt1qsettled" + walletState.value = WalletState(onchainAddress = address) + sut.showSheet(Sheet.Receive()) + advanceUntilIdle() + + emitNodeEvent( + event = Event.OnchainTransactionReceived( + txid = "txid", + details = TransactionDetails(amountSats = 1_000L, inputs = emptyList(), outputs = emptyList()), + ), + settledReceiveAddress = SettledReceiveAddress(address), + ) + advanceUntilIdle() + + assertNull(sut.currentSheet.value) + } + + @Test + fun `received onchain payment preserves a receive sheet for another address`() = test { + val sheet = Sheet.Receive() + walletState.value = WalletState(onchainAddress = "bcrt1qcurrent") + sut.showSheet(sheet) + advanceUntilIdle() + + emitNodeEvent( + event = Event.OnchainTransactionReceived( + txid = "txid", + details = TransactionDetails(amountSats = 1_000L, inputs = emptyList(), outputs = emptyList()), + ), + settledReceiveAddress = SettledReceiveAddress("bcrt1qold"), + ) + advanceUntilIdle() + + assertTrue(sut.currentSheet.value === sheet) + } + + @Test + fun `received onchain payment preserves a replacement receive sheet`() = test { + val processingStarted = CompletableDeferred() + val resumeProcessing = CompletableDeferred() + whenever { privatePaykitRepo.contactPublicKeyForPrivateOnchainAddresses(any>()) } + .doSuspendableAnswer { + processingStarted.complete(Unit) + resumeProcessing.await() + null + } + val settledAddress = "bcrt1qsettled" + walletState.value = WalletState(onchainAddress = settledAddress) + sut.showSheet(Sheet.Receive()) + advanceUntilIdle() + + emitNodeEvent( + event = Event.OnchainTransactionReceived( + txid = "txid", + details = TransactionDetails(amountSats = 1_000L, inputs = emptyList(), outputs = emptyList()), + ), + settledReceiveAddress = SettledReceiveAddress(settledAddress), + ) + processingStarted.await() + assertNull(sut.currentSheet.value) + + walletState.value = WalletState(onchainAddress = "bcrt1qreplacement") + val replacementSheet = Sheet.Receive() + sut.showSheet(replacementSheet) + advanceUntilIdle() + resumeProcessing.complete(Unit) + advanceUntilIdle() + + assertTrue(sut.currentSheet.value === replacementSheet) + } + + @Test + fun `received lightning payment preserves a non-receive sheet`() = test { + val sheet = Sheet.Pin() + walletState.value = WalletState(bolt11 = "settled-invoice") + sut.showSheet(sheet) + advanceUntilIdle() + + emitNodeEvent( + Event.PaymentReceived( + paymentId = "payment-id", + paymentHash = "payment-hash", + amountMsat = 1_000uL, + customRecords = emptyList(), + ), + settledReceiveInvoice = SettledReceiveInvoice("settled-invoice"), + ) + advanceUntilIdle() + + assertEquals(sheet, sut.currentSheet.value) + } + + @Test + fun `received lightning payment is claimed by the UI while foregrounded`() = test { + val details = NewTransactionSheetDetails( + type = NewTransactionSheetType.LIGHTNING, + direction = NewTransactionSheetDirection.RECEIVED, + paymentHashOrTxId = "payment-hash", + sats = 1L, + ) + whenever(notifyPaymentReceivedHandler(any())) + .thenReturn(Result.success(NotifyPaymentReceived.Result.ShowSheet(details))) + whenever { notifyPaymentReceivedHandler.present(any(), any(), any()) }.thenAnswer { + it.getArgument<() -> Unit>(2).invoke() + true + } + App.currentActivity = CurrentActivity().also { it.onActivityStarted(mock()) } + + emitNodeEvent( + Event.PaymentReceived( + paymentId = "payment-id", + paymentHash = "payment-hash", + amountMsat = 1_000uL, + customRecords = emptyList(), + ), + ) + advanceUntilIdle() + + verify(notifyPaymentReceivedHandler).present(any(), any(), any()) + assertEquals(details, sut.transactionSheet.value) + } + + @Test + fun `received lightning payment uses the UI handoff after the app backgrounds`() = test { + val details = NewTransactionSheetDetails( + type = NewTransactionSheetType.LIGHTNING, + direction = NewTransactionSheetDirection.RECEIVED, + paymentHashOrTxId = "payment-hash", + sats = 1L, + ) + whenever(notifyPaymentReceivedHandler(any())) + .thenReturn(Result.success(NotifyPaymentReceived.Result.ShowSheet(details))) + whenever { notifyPaymentReceivedHandler.present(any(), any(), any()) }.thenAnswer { + it.getArgument<() -> Unit>(2).invoke() + true + } + App.currentActivity = CurrentActivity() + + emitNodeEvent( + Event.PaymentReceived( + paymentId = "payment-id", + paymentHash = "payment-hash", + amountMsat = 1_000uL, + customRecords = emptyList(), + ), + ) + advanceUntilIdle() + + verify(notifyPaymentReceivedHandler).present(any(), any(), any()) + assertEquals(details, sut.transactionSheet.value) + } + + @Test + fun `received lightning payment skips the sheet when the service owns the presentation`() = test { + val details = NewTransactionSheetDetails( + type = NewTransactionSheetType.LIGHTNING, + direction = NewTransactionSheetDirection.RECEIVED, + paymentHashOrTxId = "payment-hash", + sats = 1L, + ) + whenever(notifyPaymentReceivedHandler(any())) + .thenReturn(Result.success(NotifyPaymentReceived.Result.ShowSheet(details))) + whenever { notifyPaymentReceivedHandler.present(any(), any(), any()) }.thenReturn(false) + App.currentActivity = CurrentActivity() + + emitNodeEvent( + Event.PaymentReceived( + paymentId = "payment-id", + paymentHash = "payment-hash", + amountMsat = 1_000uL, + customRecords = emptyList(), + ), + ) + advanceUntilIdle() + + verify(notifyPaymentReceivedHandler).present(any(), any(), any()) + assertEquals(NewTransactionSheetDetails.EMPTY, sut.transactionSheet.value) + } + + @Test + fun `received lightning payment preserves a receive sheet opened during activity sync`() = test { + val processingStarted = CompletableDeferred() + val resumeProcessing = CompletableDeferred() + whenever(activityRepo.notifyPaymentActivityChanged()).doSuspendableAnswer { + processingStarted.complete(Unit) + resumeProcessing.await() + } + walletState.value = WalletState(bolt11 = "settled-invoice") + sut.showSheet(Sheet.Receive()) + advanceUntilIdle() + + emitNodeEvent( + event = Event.PaymentReceived( + paymentId = "payment-id", + paymentHash = "payment-hash", + amountMsat = 1_000uL, + customRecords = emptyList(), + ), + settledReceiveInvoice = SettledReceiveInvoice("settled-invoice"), + ) + processingStarted.await() + + walletState.value = WalletState(bolt11 = "replacement-invoice") + val replacementSheet = Sheet.Receive() + sut.showSheet(replacementSheet) + advanceUntilIdle() + resumeProcessing.complete(Unit) + advanceUntilIdle() + + assertTrue(sut.currentSheet.value === replacementSheet) + } + + @Test + fun `received lightning payment dismisses the settled receive sheet before activity sync`() = test { + val processingStarted = CompletableDeferred() + val resumeProcessing = CompletableDeferred() + whenever(activityRepo.notifyPaymentActivityChanged()).doSuspendableAnswer { + processingStarted.complete(Unit) + resumeProcessing.await() + } + walletState.value = WalletState(bolt11 = "settled-invoice") + val receiveSheet = Sheet.Receive() + sut.showSheet(receiveSheet) + advanceUntilIdle() + + emitNodeEvent( + event = Event.PaymentReceived( + paymentId = "payment-id", + paymentHash = "payment-hash", + amountMsat = 1_000uL, + customRecords = emptyList(), + ), + settledReceiveInvoice = SettledReceiveInvoice("settled-invoice"), + ) + processingStarted.await() + assertNull(sut.currentSheet.value) + + walletState.value = WalletState(bolt11 = "replacement-invoice") + advanceUntilIdle() + resumeProcessing.complete(Unit) + advanceUntilIdle() + + assertNull(sut.currentSheet.value) + } + @Test fun `preserveContactPaymentContext moves active context to pending`() = test { val paymentHash = "pending_hash" @@ -1118,7 +1463,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setSendEvent(SendEvent.PayConfirmed) advanceUntilIdle() - nodeEvents.emit( + emitNodeEvent( Event.PaymentSuccessful( paymentId = "payment_id", paymentHash = paymentHash, @@ -1195,7 +1540,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setSendEvent(SendEvent.PayConfirmed) advanceUntilIdle() - nodeEvents.emit( + emitNodeEvent( Event.PaymentSuccessful( paymentId = "payment_id", paymentHash = paymentHash, @@ -1214,7 +1559,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() clearInvocations(publicPaykitRepo) - nodeEvents.emit( + emitNodeEvent( Event.ChannelReady( channelId = "testChannelId", userChannelId = "testUserChannelId", @@ -1234,7 +1579,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() clearInvocations(publicPaykitRepo) - nodeEvents.emit( + emitNodeEvent( Event.ChannelClosed( channelId = "testChannelId", userChannelId = "testUserChannelId", diff --git a/changelog.d/next/1086.fixed.md b/changelog.d/next/1086.fixed.md new file mode 100644 index 0000000000..972cc48b82 --- /dev/null +++ b/changelog.d/next/1086.fixed.md @@ -0,0 +1 @@ +Receive requests now refresh reliably after a payment is completed.