Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,6 @@ class LightningNodeService : Service() {
}

private suspend fun handlePaymentReceived(event: Event) {
if (event !is Event.PaymentReceived && event !is Event.OnchainTransactionReceived) return
val command = NotifyPaymentReceived.Command.from(event, includeNotification = true) ?: return

notifyPaymentReceivedHandler(command).onSuccess { result ->
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package to.bitkit.domain.commands

import org.lightningdevkit.ldknode.Event
import org.lightningdevkit.ldknode.TransactionDetails
import to.bitkit.models.NewTransactionSheetDetails
import to.bitkit.models.NotificationDetails

Expand All @@ -14,10 +15,19 @@ sealed interface NotifyPaymentReceived {
override val includeNotification: Boolean = false,
) : Command

/**
* An incoming onchain transaction. [confirmationTime] is the block timestamp in seconds since the
* UNIX epoch, set when the wallet first saw the transaction already confirmed without a prior
* mempool event.
*/
data class Onchain(
val event: Event.OnchainTransactionReceived,
val txid: String,
val details: TransactionDetails,
val confirmationTime: ULong? = null,
override val includeNotification: Boolean = false,
) : Command
) : Command {
val isConfirmedOnly: Boolean get() = confirmationTime != null
}

companion object {
fun from(event: Event, includeNotification: Boolean = false): Command? =
Expand All @@ -28,7 +38,15 @@ sealed interface NotifyPaymentReceived {
)

is Event.OnchainTransactionReceived -> Onchain(
event = event,
txid = event.txid,
details = event.details,
includeNotification = includeNotification,
)

is Event.OnchainTransactionConfirmed -> Onchain(
txid = event.txid,
details = event.details,
confirmationTime = event.confirmationTime,
includeNotification = includeNotification,
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,32 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import to.bitkit.di.IoDispatcher
import to.bitkit.ext.nowMillis
import to.bitkit.ext.runSuspendCatching
import to.bitkit.models.NewTransactionSheetDetails
import to.bitkit.models.NewTransactionSheetDirection
import to.bitkit.models.NewTransactionSheetType
import to.bitkit.models.msatCeilOf
import to.bitkit.repositories.ActivityRepo
import to.bitkit.repositories.BackupRepo
import to.bitkit.services.MigrationService
import to.bitkit.utils.Logger
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Clock
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.time.ExperimentalTime

@OptIn(ExperimentalTime::class)
@Singleton
class NotifyPaymentReceivedHandler @Inject constructor(
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val activityRepo: ActivityRepo,
private val backupRepo: BackupRepo,
private val migrationService: MigrationService,
private val clock: Clock,
private val receivedNotificationContent: ReceivedNotificationContent,
) {
private val presentationClaimsLock = Any()
Expand Down Expand Up @@ -86,7 +98,7 @@ class NotifyPaymentReceivedHandler @Inject constructor(

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}"
is NotifyPaymentReceived.Command.Onchain -> "onchain:${command.txid}"
}

private suspend fun shouldShowLightning(command: NotifyPaymentReceived.Command.Lightning): Boolean {
Expand All @@ -96,25 +108,60 @@ class NotifyPaymentReceivedHandler @Inject constructor(
}

private suspend fun shouldShowOnchain(command: NotifyPaymentReceived.Command.Onchain): Boolean {
activityRepo.handleOnchainTransactionReceived(command.event.txid, command.event.details)
if (command.event.details.amountSats <= 0) return false
if (command.isConfirmedOnly) {
if (command.details.amountSats <= 0) return false
if (!canShowConfirmedOnly(command)) return false
applyConfirmationIfMissing(command)
} else {
activityRepo.handleOnchainTransactionReceived(command.txid, command.details)
if (command.details.amountSats <= 0) return false
}

delay(DELAY_FOR_ACTIVITY_SYNC_MS)
val shouldShowSheet = retryShouldShowReceivedSheet(
command.event.txid,
command.event.details.amountSats.toULong(),
command.txid,
command.details.amountSats.toULong(),
)
return shouldShowSheet
}

private suspend fun applyConfirmationIfMissing(command: NotifyPaymentReceived.Command.Onchain) {
if (activityRepo.getOnchainActivityByTxId(command.txid)?.confirmed == true) {
Logger.debug("Skipping confirmed activity update for '${command.txid}', already applied", context = TAG)
return
}
activityRepo.handleOnchainTransactionConfirmed(command.txid, command.details)
}

private suspend fun canShowConfirmedOnly(command: NotifyPaymentReceived.Command.Onchain): Boolean {
val confirmationTime = command.confirmationTime ?: return false
if (backupRepo.isRestoring.value) {
Logger.debug("Skipping confirmed-only receive '${command.txid}' during restore", context = TAG)
return false
}
if (migrationService.isShowingMigrationLoading.value || migrationService.needsPostMigrationSync()) {
Logger.debug("Skipping confirmed-only receive '${command.txid}' during migration", context = TAG)
return false
}
val age = nowMillis(clock).milliseconds - confirmationTime.toLong().seconds
if (age.absoluteValue > MAX_CONFIRMED_ONLY_AGE) {
Comment thread
jvsena42 marked this conversation as resolved.
Logger.debug(
"Skipping confirmed-only receive '${command.txid}' confirmed at '$confirmationTime'",
context = TAG,
)
return false
}
return true
}

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)
is NotifyPaymentReceived.Command.Onchain -> activityRepo.markOnchainActivityAsSeen(command.txid)
}
}

Expand All @@ -134,11 +181,11 @@ class NotifyPaymentReceivedHandler @Inject constructor(
direction = NewTransactionSheetDirection.RECEIVED,
paymentHashOrTxId = when (command) {
is NotifyPaymentReceived.Command.Lightning -> command.event.paymentHash
is NotifyPaymentReceived.Command.Onchain -> command.event.txid
is NotifyPaymentReceived.Command.Onchain -> command.txid
},
sats = when (command) {
is NotifyPaymentReceived.Command.Lightning -> msatCeilOf(command.event.amountMsat).toLong()
is NotifyPaymentReceived.Command.Onchain -> command.event.details.amountSats
is NotifyPaymentReceived.Command.Onchain -> command.details.amountSats
},
)

Expand All @@ -152,5 +199,14 @@ class NotifyPaymentReceivedHandler @Inject constructor(
private const val DELAY_FOR_ACTIVITY_SYNC_MS = 500L
private const val RETRY_DELAY_MS = 300L
private const val MAX_RETRIES = 3

/**
* Max distance between a confirmed-only transaction's block timestamp and the device clock for it to
* count as a new receive. Older confirmations, such as those replayed by a full wallet scan after a
* restore, stay silent. The block timestamp is used instead of the node's best block height, which
* only advances with the lightning wallet sync and can lag the onchain sync that emits the event.
* The distance is absolute because block timestamps and device clocks can run ahead of each other.
*/
private val MAX_CONFIRMED_ONLY_AGE = 1.hours
}
}
1 change: 1 addition & 0 deletions app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1577,6 +1577,7 @@ class AppViewModel @Inject constructor(

private suspend fun handleOnchainTransactionConfirmed(event: Event.OnchainTransactionConfirmed) {
activityRepo.handleOnchainTransactionConfirmed(event.txid, event.details)
notifyPaymentReceived(event)
}

private suspend fun handleOnchainTransactionEvicted(event: Event.OnchainTransactionEvicted) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,12 @@ import org.junit.Rule
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.doAnswer
import org.mockito.kotlin.doSuspendableAnswer
import org.mockito.kotlin.eq
import org.mockito.kotlin.inOrder
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
Expand Down Expand Up @@ -455,6 +457,76 @@ class LightningNodeServiceTest : BaseUnitTest() {
assertEquals($$"Received ₿ 100 ($0.10)", body)
}

@Test
fun `confirmed-only onchain receive in background shows notification`() = test {
val sheet = NewTransactionSheetDetails(
type = NewTransactionSheetType.ONCHAIN,
direction = NewTransactionSheetDirection.RECEIVED,
paymentHashOrTxId = "confirmed_txid",
sats = 5000L,
)
val notification = NotificationDetails(
title = context.getString(R.string.notification__received__title),
body = "Received ₿ 5 000",
)
whenever(notifyPaymentReceivedHandler.invoke(any()))
.thenReturn(Result.success(NotifyPaymentReceived.Result.ShowNotification(sheet, notification)))
startService()
testScheduler.advanceUntilIdle()

val details = TransactionDetails(amountSats = 5000L, inputs = emptyList(), outputs = emptyList())
capturedHandler?.invoke(
Event.OnchainTransactionConfirmed(
txid = "confirmed_txid",
blockHash = "block_hash",
blockHeight = 100u,
confirmationTime = 0uL,
details = details,
),
)
testScheduler.advanceUntilIdle()

val expectedCommand = NotifyPaymentReceived.Command.Onchain(
txid = "confirmed_txid",
details = details,
confirmationTime = 0uL,
includeNotification = true,
)
verify(notifyPaymentReceivedHandler).invoke(expectedCommand)
verify(notifyPaymentReceivedHandler).present(eq(expectedCommand), any(), any())
verify(cacheStore).setBackgroundReceive(sheet)
val receivedNotifications = Shadows.shadowOf(context.notificationManager).allNotifications.filter {
it.extras.getString(Notification.EXTRA_TITLE) == context.getString(R.string.notification__received__title)
}
assertEquals(1, receivedNotifications.size)
}

@Test
fun `skipped confirmed-only onchain receive shows no notification`() = test {
whenever(notifyPaymentReceivedHandler.invoke(any()))
.thenReturn(Result.success(NotifyPaymentReceived.Result.Skip))
startService()
testScheduler.advanceUntilIdle()

capturedHandler?.invoke(
Event.OnchainTransactionConfirmed(
txid = "old_txid",
blockHash = "block_hash",
blockHeight = 1u,
confirmationTime = 0uL,
details = TransactionDetails(amountSats = 5000L, inputs = emptyList(), outputs = 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, never()).present(any(), any(), any())
verify(cacheStore, never()).setBackgroundReceive(any())
}

@Test
fun `pending payment success in background shows notification`() = test {
val sentTitle = context.getString(R.string.wallet__toast_payment_sent_title)
Expand Down
Loading
Loading