diff --git a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt index 79849ab9c9..1c101a1b07 100644 --- a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -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 -> diff --git a/app/src/main/java/to/bitkit/data/SettingsStore.kt b/app/src/main/java/to/bitkit/data/SettingsStore.kt index c1d970ce05..6983abf59a 100644 --- a/app/src/main/java/to/bitkit/data/SettingsStore.kt +++ b/app/src/main/java/to/bitkit/data/SettingsStore.kt @@ -198,6 +198,12 @@ data class SettingsData( val selectedAddressType: String = DEFAULT_ADDRESS_TYPE_STRING, val addressTypesToMonitor: List = listOf(DEFAULT_ADDRESS_TYPE_STRING), val pendingRestoreAddressTypePrune: Boolean = false, + /** + * After a seed restore, suppresses the on-chain received sheet for historical transactions replayed by the + * post-restore sync. Set when the user taps Get Started on the restore success screen and cleared by the + * first on-chain sync completion after that, which marks the replayed activities as seen. + */ + val pendingRestoreActivitySeen: Boolean = false, ) data class BalanceUnitSwitch( diff --git a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceived.kt b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceived.kt index 1e9c92a93b..61cb0737ac 100644 --- a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceived.kt +++ b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceived.kt @@ -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 @@ -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? = @@ -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, ) 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 5aa105096a..26552f880b 100644 --- a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt +++ b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt @@ -2,22 +2,38 @@ package to.bitkit.domain.commands import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext +import to.bitkit.data.SettingsStore 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) +@Suppress("LongParameterList") @Singleton class NotifyPaymentReceivedHandler @Inject constructor( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val activityRepo: ActivityRepo, + private val backupRepo: BackupRepo, + private val migrationService: MigrationService, + private val settingsStore: SettingsStore, + private val clock: Clock, private val receivedNotificationContent: ReceivedNotificationContent, ) { private val presentationClaimsLock = Any() @@ -86,7 +102,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 { @@ -96,17 +112,57 @@ 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 + } + + if (settingsStore.data.first().pendingRestoreActivitySeen) { + Logger.debug("Skipping onchain receive '${command.txid}' until the first sync after restore", context = TAG) + 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) { + 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 -> { @@ -114,7 +170,7 @@ class NotifyPaymentReceivedHandler @Inject constructor( activityRepo.markActivityAsSeen(paymentId) } - is NotifyPaymentReceived.Command.Onchain -> activityRepo.markOnchainActivityAsSeen(command.event.txid) + is NotifyPaymentReceived.Command.Onchain -> activityRepo.markOnchainActivityAsSeen(command.txid) } } @@ -134,11 +190,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 }, ) @@ -152,5 +208,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 } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 1503501d54..a470972566 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -73,6 +73,7 @@ import org.lightningdevkit.ldknode.NodeException import org.lightningdevkit.ldknode.PaymentFailureReason import org.lightningdevkit.ldknode.PaymentId import org.lightningdevkit.ldknode.SpendableUtxo +import org.lightningdevkit.ldknode.SyncType import org.lightningdevkit.ldknode.Txid import to.bitkit.BuildConfig import to.bitkit.R @@ -1339,7 +1340,7 @@ class AppViewModel @Inject constructor( is Event.ProbeSuccessful -> Unit is Event.SpliceFailed -> Unit is Event.SplicePending -> Unit - is Event.SyncCompleted -> handleSyncCompleted() + is Event.SyncCompleted -> handleSyncCompleted(event) is Event.SyncProgress -> Unit } }.onFailure { e -> @@ -1427,7 +1428,9 @@ class AppViewModel @Inject constructor( } } - private suspend fun handleSyncCompleted() { + private suspend fun handleSyncCompleted(event: Event.SyncCompleted) { + if (event.syncType == SyncType.ONCHAIN_WALLET) completePendingRestoreActivitySeen() + val isShowingLoading = migrationService.isShowingMigrationLoading.value val isRestoringRemote = migrationService.isRestoringFromRNRemoteBackup.value val needsPostMigrationSync = migrationService.needsPostMigrationSync() @@ -1457,6 +1460,14 @@ class AppViewModel @Inject constructor( } } + private suspend fun completePendingRestoreActivitySeen() { + if (!settingsStore.data.first().pendingRestoreActivitySeen) return + Logger.info("Marking activities replayed by the first sync after restore as seen", context = TAG) + activityRepo.markAllUnseenActivitiesAsSeen().onSuccess { + settingsStore.update { settings -> settings.copy(pendingRestoreActivitySeen = false) } + } + } + private suspend fun completeRNRemoteBackupRestore() { val channelMigration = buildChannelMigrationIfAvailable() @@ -1577,6 +1588,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) { diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 0315c8bbd2..99d3a53f64 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -254,8 +254,12 @@ class WalletViewModel @Inject constructor( fun onRestoreContinue() { viewModelScope.launch(bgDispatcher) { - if (!settingsStore.restoredMonitoredTypesFromBackup) { - settingsStore.update { it.copy(pendingRestoreAddressTypePrune = true) } + val shouldPrune = !settingsStore.restoredMonitoredTypesFromBackup + settingsStore.update { + it.copy( + pendingRestoreAddressTypePrune = it.pendingRestoreAddressTypePrune || shouldPrune, + pendingRestoreActivitySeen = true, + ) } } _restoreState.update { RestoreState.Settled } diff --git a/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt index 8ad9f6a731..a8fc569efe 100644 --- a/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt +++ b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt @@ -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 @@ -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) 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 af7a08cdbc..0d78fcec74 100644 --- a/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt +++ b/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt @@ -1,7 +1,9 @@ package to.bitkit.domain.commands import android.content.Context -import kotlinx.coroutines.flow.flowOf +import com.synonym.bitkitcore.OnchainActivity +import com.synonym.bitkitcore.PaymentType +import kotlinx.coroutines.flow.MutableStateFlow import org.junit.Before import org.junit.Test import org.lightningdevkit.ldknode.Event @@ -18,25 +20,44 @@ import org.mockito.kotlin.whenever import to.bitkit.R import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore +import to.bitkit.ext.create import to.bitkit.models.ConvertedAmount import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType import to.bitkit.models.WalletScope import to.bitkit.repositories.ActivityRepo +import to.bitkit.repositories.BackupRepo import to.bitkit.repositories.CurrencyRepo +import to.bitkit.services.MigrationService 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 - +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +@OptIn(ExperimentalTime::class) class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { + companion object { + private val NOW = Instant.fromEpochSeconds(1_700_000_000L) + } private val context: Context = mock() private val activityRepo: ActivityRepo = mock() private val currencyRepo: CurrencyRepo = mock() private val settingsStore: SettingsStore = mock() + private val clock: Clock = mock() + private val backupRepo: BackupRepo = mock() + private val migrationService: MigrationService = mock() + private val isRestoring = MutableStateFlow(false) + private val isShowingMigrationLoading = MutableStateFlow(false) + private val settingsData = MutableStateFlow(SettingsData()) private lateinit var sut: NotifyPaymentReceivedHandler @@ -44,7 +65,11 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { fun setUp() { whenever(context.getString(R.string.notification__received__title)).thenReturn("Payment Received") whenever(context.getString(any(), any())).thenReturn("Received amount") - whenever(settingsStore.data).thenReturn(flowOf(SettingsData())) + whenever(settingsStore.data).thenReturn(settingsData) + whenever(backupRepo.isRestoring).thenReturn(isRestoring) + whenever(migrationService.isShowingMigrationLoading).thenReturn(isShowingMigrationLoading) + whenever { migrationService.needsPostMigrationSync() }.thenReturn(false) + whenever(clock.now()).thenReturn(NOW) whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())).thenReturn( Result.success( ConvertedAmount( @@ -61,6 +86,10 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { sut = NotifyPaymentReceivedHandler( ioDispatcher = testDispatcher, activityRepo = activityRepo, + backupRepo = backupRepo, + migrationService = migrationService, + settingsStore = settingsStore, + clock = clock, receivedNotificationContent = ReceivedNotificationContent( context = context, currencyRepo = currencyRepo, @@ -133,12 +162,8 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { val details = mock { on { amountSats } doReturn 5000L } - val event = mock { - on { txid } doReturn "txid456" - on { this.details } doReturn details - } whenever(activityRepo.shouldShowReceivedSheet(any(), any())).thenReturn(true) - val command = NotifyPaymentReceived.Command.Onchain(event = event) + val command = NotifyPaymentReceived.Command.Onchain(txid = "txid456", details = details) val result = sut(command) @@ -163,12 +188,8 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { val details = mock { on { amountSats } doReturn 5000L } - val event = mock { - on { txid } doReturn "txid456" - on { this.details } doReturn details - } whenever(activityRepo.shouldShowReceivedSheet(any(), any())).thenReturn(false) - val command = NotifyPaymentReceived.Command.Onchain(event = event) + val command = NotifyPaymentReceived.Command.Onchain(txid = "txid456", details = details) val result = sut(command) @@ -182,12 +203,8 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { val details = mock { on { amountSats } doReturn 7500L } - val event = mock { - on { txid } doReturn "txid789" - on { this.details } doReturn details - } whenever(activityRepo.shouldShowReceivedSheet(any(), any())).thenReturn(true) - val command = NotifyPaymentReceived.Command.Onchain(event = event) + val command = NotifyPaymentReceived.Command.Onchain(txid = "txid789", details = details) sut(command) sut.claimPresentation(command) @@ -205,12 +222,8 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { val details = mock { on { amountSats } doReturn 5000L } - val event = mock { - on { txid } doReturn "txid456" - on { this.details } doReturn details - } whenever(activityRepo.shouldShowReceivedSheet(any(), any())).thenReturn(false) - val command = NotifyPaymentReceived.Command.Onchain(event = event) + val command = NotifyPaymentReceived.Command.Onchain(txid = "txid456", details = details) sut(command) @@ -326,4 +339,291 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { assertFalse(sut.claimPresentation(command) { false }) assertTrue(sut.claimPresentation(command)) } + + @Test + fun `confirmed-only recent onchain receive returns ShowSheet`() = test { + val details = TransactionDetails(amountSats = 5000L, inputs = emptyList(), outputs = emptyList()) + whenever(activityRepo.shouldShowReceivedSheet(any(), any())).thenReturn(true) + val command = confirmedCommand(txid = "txidConfirmed", details = details, age = Duration.ZERO) + + val result = sut(command).getOrThrow() + + assertTrue(result is NotifyPaymentReceived.Result.ShowSheet) + assertEquals(NewTransactionSheetType.ONCHAIN, result.sheet.type) + assertEquals(NewTransactionSheetDirection.RECEIVED, result.sheet.direction) + assertEquals("txidConfirmed", result.sheet.paymentHashOrTxId) + assertEquals(5000L, result.sheet.sats) + inOrder(activityRepo) { + verify(activityRepo).handleOnchainTransactionConfirmed("txidConfirmed", details) + verify(activityRepo).shouldShowReceivedSheet("txidConfirmed", 5000uL) + } + verify(activityRepo, never()).handleOnchainTransactionReceived(any(), any()) + + assertTrue(sut.present(command) {}) + verify(activityRepo).markOnchainActivityAsSeen("txidConfirmed", WalletScope.default) + } + + @Test + fun `confirmed-only onchain receive does not reapply a confirmation already stored`() = test { + val details = TransactionDetails(amountSats = 5000L, inputs = emptyList(), outputs = emptyList()) + whenever(activityRepo.shouldShowReceivedSheet(any(), any())).thenReturn(true) + whenever(activityRepo.getOnchainActivityByTxId(eq("txidStored"), eq(WalletScope.default))) + .thenReturn(onchainActivity(txId = "txidStored", confirmed = true)) + val command = confirmedCommand(txid = "txidStored", details = details, age = Duration.ZERO) + + val result = sut(command).getOrThrow() + + assertTrue(result is NotifyPaymentReceived.Result.ShowSheet) + verify(activityRepo, never()).handleOnchainTransactionConfirmed(any(), any()) + verify(activityRepo).shouldShowReceivedSheet("txidStored", 5000uL) + } + + @Test + fun `confirmed-only onchain receive applies the confirmation when the activity is still unconfirmed`() = test { + val details = TransactionDetails(amountSats = 5000L, inputs = emptyList(), outputs = emptyList()) + whenever(activityRepo.shouldShowReceivedSheet(any(), any())).thenReturn(true) + whenever(activityRepo.getOnchainActivityByTxId(eq("txidUnconfirmed"), eq(WalletScope.default))) + .thenReturn(onchainActivity(txId = "txidUnconfirmed", confirmed = false)) + val command = confirmedCommand(txid = "txidUnconfirmed", details = details, age = Duration.ZERO) + + val result = sut(command).getOrThrow() + + assertTrue(result is NotifyPaymentReceived.Result.ShowSheet) + verify(activityRepo).handleOnchainTransactionConfirmed("txidUnconfirmed", details) + } + + @Test + fun `confirmed-only onchain receive returns ShowNotification when includeNotification is true`() = test { + val details = TransactionDetails(amountSats = 5000L, inputs = emptyList(), outputs = emptyList()) + whenever(activityRepo.shouldShowReceivedSheet(any(), any())).thenReturn(true) + val command = confirmedCommand( + txid = "txidConfirmed", + details = details, + age = 59.minutes, + includeNotification = true, + ) + + val result = sut(command).getOrThrow() + + assertTrue(result is NotifyPaymentReceived.Result.ShowNotification) + assertEquals("txidConfirmed", result.sheet.paymentHashOrTxId) + assertEquals("Payment Received", result.notification.title) + } + + @Test + fun `confirmed-only onchain receive slightly ahead of the device clock returns ShowSheet`() = test { + val details = TransactionDetails(amountSats = 5000L, inputs = emptyList(), outputs = emptyList()) + whenever(activityRepo.shouldShowReceivedSheet(any(), any())).thenReturn(true) + val command = confirmedCommand(txid = "txidAhead", details = details, age = (-5).minutes) + + val result = sut(command).getOrThrow() + + assertTrue(result is NotifyPaymentReceived.Result.ShowSheet) + } + + @Test + fun `received then confirmed onchain payment is presented once`() = test { + val details = TransactionDetails(amountSats = 5000L, inputs = emptyList(), outputs = emptyList()) + whenever(activityRepo.shouldShowReceivedSheet(any(), any())).thenReturn(true) + val received = NotifyPaymentReceived.Command.Onchain(txid = "txidOnce", details = details) + val confirmed = confirmedCommand(txid = "txidOnce", details = details, age = Duration.ZERO) + var presentationCount = 0 + + val receivedResult = sut(received).getOrThrow() + assertTrue(receivedResult is NotifyPaymentReceived.Result.ShowSheet) + assertTrue(sut.present(received) { presentationCount += 1 }) + + val confirmedResult = sut(confirmed).getOrThrow() + + assertTrue(confirmedResult is NotifyPaymentReceived.Result.Skip) + assertFalse(sut.present(confirmed) { presentationCount += 1 }) + assertEquals(1, presentationCount) + verify(activityRepo, never()).handleOnchainTransactionConfirmed(any(), any()) + } + + @Test + fun `confirmed-only onchain receive confirmed outside the recent window returns Skip`() = test { + val details = TransactionDetails(amountSats = 5000L, inputs = emptyList(), outputs = emptyList()) + whenever(activityRepo.shouldShowReceivedSheet(any(), any())).thenReturn(true) + val command = confirmedCommand(txid = "txidOld", details = details, age = 61.minutes) + + val result = sut(command).getOrThrow() + + assertTrue(result is NotifyPaymentReceived.Result.Skip) + verify(activityRepo, never()).handleOnchainTransactionConfirmed(any(), any()) + verify(activityRepo, never()).shouldShowReceivedSheet(any(), any()) + } + + @Test + fun `confirmed-only onchain receive replayed from old history returns Skip`() = test { + val details = TransactionDetails(amountSats = 5000L, inputs = emptyList(), outputs = emptyList()) + whenever(activityRepo.shouldShowReceivedSheet(any(), any())).thenReturn(true) + val command = confirmedCommand(txid = "txidReplay", details = details, age = (24 * 365).hours) + + val result = sut(command).getOrThrow() + + assertTrue(result is NotifyPaymentReceived.Result.Skip) + verify(activityRepo, never()).shouldShowReceivedSheet(any(), any()) + } + + @Test + fun `confirmed-only onchain receive far ahead of the device clock returns Skip`() = test { + val details = TransactionDetails(amountSats = 5000L, inputs = emptyList(), outputs = emptyList()) + whenever(activityRepo.shouldShowReceivedSheet(any(), any())).thenReturn(true) + val command = confirmedCommand(txid = "txidFuture", details = details, age = (-2).hours) + + val result = sut(command).getOrThrow() + + assertTrue(result is NotifyPaymentReceived.Result.Skip) + verify(activityRepo, never()).shouldShowReceivedSheet(any(), any()) + } + + @Test + fun `confirmed-only onchain send returns Skip`() = test { + val details = TransactionDetails(amountSats = -5000L, inputs = emptyList(), outputs = emptyList()) + whenever(activityRepo.shouldShowReceivedSheet(any(), any())).thenReturn(true) + val command = confirmedCommand(txid = "txidSent", details = details, age = Duration.ZERO) + + val result = sut(command).getOrThrow() + + assertTrue(result is NotifyPaymentReceived.Result.Skip) + verify(activityRepo, never()).handleOnchainTransactionConfirmed(any(), any()) + verify(activityRepo, never()).shouldShowReceivedSheet(any(), any()) + } + + @Test + fun `confirmed-only onchain receive returns Skip while a restore is in progress`() = test { + isRestoring.value = true + val details = TransactionDetails(amountSats = 5000L, inputs = emptyList(), outputs = emptyList()) + whenever(activityRepo.shouldShowReceivedSheet(any(), any())).thenReturn(true) + val command = confirmedCommand(txid = "txidRestore", details = details, age = Duration.ZERO) + + val result = sut(command).getOrThrow() + + assertTrue(result is NotifyPaymentReceived.Result.Skip) + verify(activityRepo, never()).handleOnchainTransactionConfirmed(any(), any()) + verify(activityRepo, never()).shouldShowReceivedSheet(any(), any()) + } + + @Test + fun `confirmed-only onchain receive returns Skip while a migration is in progress`() = test { + whenever(migrationService.needsPostMigrationSync()).thenReturn(true) + val details = TransactionDetails(amountSats = 5000L, inputs = emptyList(), outputs = emptyList()) + whenever(activityRepo.shouldShowReceivedSheet(any(), any())).thenReturn(true) + val command = confirmedCommand(txid = "txidMigration", details = details, age = Duration.ZERO) + + val result = sut(command).getOrThrow() + + assertTrue(result is NotifyPaymentReceived.Result.Skip) + verify(activityRepo, never()).shouldShowReceivedSheet(any(), any()) + } + + @Test + fun `onchain mempool receive returns Skip while the first sync after restore is pending`() = test { + settingsData.value = SettingsData(pendingRestoreActivitySeen = true) + val details = TransactionDetails(amountSats = 5000L, inputs = emptyList(), outputs = emptyList()) + whenever(activityRepo.shouldShowReceivedSheet(any(), any())).thenReturn(true) + val command = NotifyPaymentReceived.Command.Onchain(txid = "txidRestored", details = details) + + val result = sut(command).getOrThrow() + + assertEquals(NotifyPaymentReceived.Result.Skip, result) + verify(activityRepo).handleOnchainTransactionReceived("txidRestored", details) + verify(activityRepo, never()).shouldShowReceivedSheet(any(), any()) + verify(activityRepo, never()).markOnchainActivityAsSeen(any(), any()) + } + + @Test + fun `confirmed-only onchain receive returns Skip while the first sync after restore is pending`() = test { + settingsData.value = SettingsData(pendingRestoreActivitySeen = true) + val details = TransactionDetails(amountSats = 5000L, inputs = emptyList(), outputs = emptyList()) + whenever(activityRepo.shouldShowReceivedSheet(any(), any())).thenReturn(true) + val command = confirmedCommand(txid = "txidRestored", details = details, age = Duration.ZERO) + + val result = sut(command).getOrThrow() + + assertEquals(NotifyPaymentReceived.Result.Skip, result) + verify(activityRepo).handleOnchainTransactionConfirmed("txidRestored", details) + verify(activityRepo, never()).shouldShowReceivedSheet(any(), any()) + verify(activityRepo, never()).markOnchainActivityAsSeen(any(), any()) + } + + @Test + fun `confirmed-only onchain receive already marked seen after restore returns Skip`() = test { + val details = TransactionDetails(amountSats = 5000L, inputs = emptyList(), outputs = emptyList()) + whenever(activityRepo.shouldShowReceivedSheet("txidHistorical", 5000uL)).thenReturn(false) + val command = confirmedCommand(txid = "txidHistorical", details = details, age = Duration.ZERO) + + val result = sut(command).getOrThrow() + + assertTrue(result is NotifyPaymentReceived.Result.Skip) + verify(activityRepo, never()).markOnchainActivityAsSeen(any(), any()) + } + + @Test + fun `onchain receive notifies again once the first sync after restore is done`() = test { + settingsData.value = SettingsData(pendingRestoreActivitySeen = true) + val details = TransactionDetails(amountSats = 5000L, inputs = emptyList(), outputs = emptyList()) + whenever(activityRepo.shouldShowReceivedSheet(any(), any())).thenReturn(true) + val historical = confirmedCommand(txid = "txidHistorical", details = details, age = Duration.ZERO) + val fresh = NotifyPaymentReceived.Command.Onchain(txid = "txidFresh", details = details) + + assertEquals(NotifyPaymentReceived.Result.Skip, sut(historical).getOrThrow()) + + settingsData.value = SettingsData(pendingRestoreActivitySeen = false) + val result = sut(fresh).getOrThrow() + + assertTrue(result is NotifyPaymentReceived.Result.ShowSheet) + assertEquals("txidFresh", result.sheet.paymentHashOrTxId) + assertTrue(sut.present(fresh) {}) + verify(activityRepo).markOnchainActivityAsSeen("txidFresh", WalletScope.default) + verify(activityRepo, never()).markOnchainActivityAsSeen("txidHistorical", WalletScope.default) + } + + @Test + fun `from maps a confirmed onchain event to a confirmed-only command`() { + val details = TransactionDetails(amountSats = 5000L, inputs = emptyList(), outputs = emptyList()) + val event = Event.OnchainTransactionConfirmed( + txid = "txidMapped", + blockHash = "blockHash", + blockHeight = 100u, + confirmationTime = 1_700_000_000uL, + details = details, + ) + + val command = NotifyPaymentReceived.Command.from(event, includeNotification = true) + + assertEquals( + NotifyPaymentReceived.Command.Onchain( + txid = "txidMapped", + details = details, + confirmationTime = 1_700_000_000uL, + includeNotification = true, + ), + command, + ) + } + + private fun onchainActivity(txId: String, confirmed: Boolean) = OnchainActivity.create( + id = txId, + txType = PaymentType.RECEIVED, + txId = txId, + value = 5000uL, + fee = 100uL, + address = "bc1test", + timestamp = 1_700_000_000uL, + confirmed = confirmed, + ) + + private fun confirmedCommand( + txid: String, + details: TransactionDetails, + age: Duration, + includeNotification: Boolean = false, + ) = NotifyPaymentReceived.Command.Onchain( + txid = txid, + details = details, + confirmationTime = (NOW - age).epochSeconds.toULong(), + includeNotification = includeNotification, + ) } diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index 9cbf3af699..3240226099 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -42,6 +42,8 @@ import to.bitkit.utils.AppError import to.bitkit.viewmodels.RestoreState import to.bitkit.viewmodels.WalletViewModel import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class WalletViewModelTest : BaseUnitTest() { @@ -252,6 +254,29 @@ class WalletViewModelTest : BaseUnitTest() { assertEquals(RestoreState.Settled, sut.restoreState.value) } + @Test + fun `onRestoreContinue should defer marking restored activities seen until the first onchain sync`() = test { + val settingsData = stubSettingsUpdate() + + sut.onRestoreContinue() + advanceUntilIdle() + + assertTrue(settingsData.value.pendingRestoreActivitySeen) + assertTrue(settingsData.value.pendingRestoreAddressTypePrune) + } + + @Test + fun `onRestoreContinue should skip address type pruning when backup had monitored types`() = test { + whenever(settingsStore.restoredMonitoredTypesFromBackup).thenReturn(true) + val settingsData = stubSettingsUpdate() + + sut.onRestoreContinue() + advanceUntilIdle() + + assertTrue(settingsData.value.pendingRestoreActivitySeen) + assertFalse(settingsData.value.pendingRestoreAddressTypePrune) + } + @Test fun `onProceedWithoutRestore should exit restore flow`() = test { val testError = Exception("Test error") @@ -500,4 +525,14 @@ class WalletViewModelTest : BaseUnitTest() { verify(testWalletRepo, never()).refreshBip21() } + + private fun stubSettingsUpdate(): MutableStateFlow { + val settingsData = MutableStateFlow(SettingsData()) + whenever { settingsStore.update(any()) }.thenAnswer { + val transform = it.getArgument<(SettingsData) -> SettingsData>(0) + settingsData.value = transform(settingsData.value) + Unit + } + return settingsData + } } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 5f47896f2c..8dcf1439d4 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -53,6 +53,7 @@ import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.NodeException import org.lightningdevkit.ldknode.PaymentFailureReason import org.lightningdevkit.ldknode.SpendableUtxo +import org.lightningdevkit.ldknode.SyncType import org.lightningdevkit.ldknode.TransactionDetails import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull @@ -4318,6 +4319,109 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(sheet, sut.currentSheet.value) } + @Test + fun `confirmed-only onchain receive shows the sheet after updating the activity`() = test { + val sheetDetails = NewTransactionSheetDetails( + type = NewTransactionSheetType.ONCHAIN, + direction = NewTransactionSheetDirection.RECEIVED, + paymentHashOrTxId = "confirmed-txid", + sats = 1_000L, + ) + whenever(notifyPaymentReceivedHandler(any())) + .thenReturn(Result.success(NotifyPaymentReceived.Result.ShowSheet(sheetDetails))) + val details = TransactionDetails(amountSats = 1_000L, inputs = emptyList(), outputs = emptyList()) + + emitNodeEvent( + Event.OnchainTransactionConfirmed( + txid = "confirmed-txid", + blockHash = "block-hash", + blockHeight = 100u, + confirmationTime = 0uL, + details = details, + ), + ) + advanceUntilIdle() + + val expectedCommand = NotifyPaymentReceived.Command.Onchain( + txid = "confirmed-txid", + details = details, + confirmationTime = 0uL, + ) + inOrder(activityRepo, notifyPaymentReceivedHandler) { + verify(activityRepo).handleOnchainTransactionConfirmed("confirmed-txid", details) + verify(notifyPaymentReceivedHandler).invoke(expectedCommand) + verify(notifyPaymentReceivedHandler).present(eq(expectedCommand), any(), any()) + } + assertEquals(sheetDetails, sut.transactionSheet.value) + } + + @Test + fun `first onchain sync after restore marks unseen activities seen and clears the pending flag`() = test { + settingsData.value = SettingsData(pendingRestoreActivitySeen = true) + whenever { activityRepo.markAllUnseenActivitiesAsSeen() }.thenReturn(Result.success(Unit)) + + emitNodeEvent(Event.SyncCompleted(syncType = SyncType.ONCHAIN_WALLET, syncedBlockHeight = 100u)) + advanceUntilIdle() + + inOrder(activityRepo, settingsStore) { + verify(activityRepo).markAllUnseenActivitiesAsSeen() + verify(settingsStore).update(any()) + } + assertFalse(settingsData.value.pendingRestoreActivitySeen) + } + + @Test + fun `first onchain sync after restore keeps the pending flag when marking activities seen fails`() = test { + settingsData.value = SettingsData(pendingRestoreActivitySeen = true) + whenever { activityRepo.markAllUnseenActivitiesAsSeen() } + .thenReturn(Result.failure(AppError("mark seen failed"))) + + emitNodeEvent(Event.SyncCompleted(syncType = SyncType.ONCHAIN_WALLET, syncedBlockHeight = 100u)) + advanceUntilIdle() + + verify(activityRepo).markAllUnseenActivitiesAsSeen() + assertTrue(settingsData.value.pendingRestoreActivitySeen) + } + + @Test + fun `lightning sync after restore keeps the pending flag and activities untouched`() = test { + settingsData.value = SettingsData(pendingRestoreActivitySeen = true) + + emitNodeEvent(Event.SyncCompleted(syncType = SyncType.LIGHTNING_WALLET, syncedBlockHeight = 100u)) + advanceUntilIdle() + + verify(activityRepo, never()).markAllUnseenActivitiesAsSeen() + assertTrue(settingsData.value.pendingRestoreActivitySeen) + } + + @Test + fun `onchain sync without a pending restore leaves unseen activities untouched`() = test { + emitNodeEvent(Event.SyncCompleted(syncType = SyncType.ONCHAIN_WALLET, syncedBlockHeight = 100u)) + advanceUntilIdle() + + verify(activityRepo, never()).markAllUnseenActivitiesAsSeen() + verify(settingsStore, never()).update(any()) + } + + @Test + fun `confirmed-only onchain receive skips the handler during migration`() = test { + whenever(migrationService.needsPostMigrationSync()).thenReturn(true) + + emitNodeEvent( + Event.OnchainTransactionConfirmed( + txid = "confirmed-txid", + blockHash = "block-hash", + blockHeight = 100u, + confirmationTime = 0uL, + details = TransactionDetails(amountSats = 1_000L, inputs = emptyList(), outputs = emptyList()), + ), + ) + advanceUntilIdle() + + verify(notifyPaymentReceivedHandler, never()).invoke(any()) + assertEquals(NewTransactionSheetDetails.EMPTY, sut.transactionSheet.value) + } + @Test fun `received lightning payment is claimed by the UI while foregrounded`() = test { val details = NewTransactionSheetDetails( diff --git a/changelog.d/next/797.fixed.md b/changelog.d/next/797.fixed.md new file mode 100644 index 0000000000..0e4d1d53c6 --- /dev/null +++ b/changelog.d/next/797.fixed.md @@ -0,0 +1 @@ +Show the received transaction sheet or notification for on-chain deposits first seen already confirmed. diff --git a/journeys/README.md b/journeys/README.md index 97803bf46f..2dfd9847c1 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -124,6 +124,7 @@ fixtures, push notifications) live in each suite's README. | [home](home) | 1 | Pull to refresh on Home; checks the app log, no README | | [node-lifecycle](node-lifecycle) | 1 | Detached LDK restart completes; a cancelled RGS server change reconciles and recovers to Running; reads the app log; no README | | [notification-permission](notification-permission) | 4 | Background-setup toggles | +| [onchain-receive](onchain-receive) | 3 | Received sheet and notification for mempool-first and confirmed-only deposits | | [payment-requests](payment-requests) | 2 | Requires a linked fixture issuer; rejected shapes are unit fixtures | | [pubky-marketplace](pubky-marketplace) | 1 | Two-wallet Paykit marketplace payment; integration fixture required | | [receive](receive) | 1 | Receive sheet tab selection; needs a spending channel, no README | @@ -158,6 +159,7 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `payment-requests/requested-resolution-failure.xml` | not ported | | `node-lifecycle/cancelled-node-restart.xml` | not ported — the routes run through Android's LDK Debug and Rapid-Gossip-Sync screens and assert on Android app-log lines | | `restore-wallet/paste-seed-fragment.xml` | not ported — the iOS Restore screen still has the 12/24-only paste guard, so the behaviour does not exist there yet | +| `onchain-receive/*` | port pending in synonymdev/bitkit-ios#588, which wires `onchainTransactionConfirmed` into the same received-sheet flow but carries no `journeys/` files. Two adaptations when it lands: iOS suppresses replayed historical receives with a `pendingRestoreActivitySeen` flag cleared by the first post-restore on-chain sync, not the one-hour block-timestamp guard used here, so a stale-confirmation step has to drive a restore instead of a clock; and iOS has no foreground-service path, so `confirmed-only-background-notification.xml` has no counterpart | | `transfers/closed-channel-transfer-settles.xml` | not ported — the closed-channel and order-closure settle rules are an iOS follow-up | | `deeplinks/*` | not ported — iOS registers the `bitkit` scheme but has no screen or sheet router | | `backup-restore/restore-keeps-tags-and-closed-channels.xml` | not ported yet — iOS already gates uploads across the whole restore (`AppScene.restoreFromMostRecentBackup` sets `BackupService.setRestoring(true)` before the timestamp probe), but still applies the three activity slices in one block (`BackupService.performFullRestoreFromLatestBackup`), which is the half this journey pins; port it with the iOS slice fix | diff --git a/journeys/onchain-receive/README.md b/journeys/onchain-receive/README.md new file mode 100644 index 0000000000..69476d5890 --- /dev/null +++ b/journeys/onchain-receive/README.md @@ -0,0 +1,29 @@ +# Onchain receive journeys + +These journeys cover the received sheet and notification for onchain deposits (issue #797). + +ldk-node emits `OnchainTransactionReceived` when the wallet sync finds a transaction in the mempool +and `OnchainTransactionConfirmed` when it confirms. A transaction that is mined before any sync sees +it in the mempool produces only the confirmed event. Both events go through +`NotifyPaymentReceivedHandler`, from `AppViewModel` in the foreground and `LightningNodeService` in +the background. + +A confirmed-only receive is shown only when its block timestamp is within one hour of the device +clock and no restore or migration is running. After a seed restore, Get Started sets +`pendingRestoreActivitySeen`, which holds every onchain received sheet and notification until the +first onchain sync completes; that sync marks all unseen activities as seen and clears the flag, so +the transactions it discovered stay silent when they later confirm while new deposits notify again. +The same rule ships on iOS in bitkit-ios#588. A full scan after a restore also replays old +confirmations, which the one-hour window keeps silent. Neither case can be driven on a funded +device; both are covered by `NotifyPaymentReceivedHandlerTest.kt` and `AppViewModelSendFlowTest.kt`. + +## Preconditions + +- Onboarded regtest wallet with the node running. Fund and mine with the `lsp` helper at the repo root. +- Wallet sync runs every 10s. For the confirmed-only journeys, run the deposit and the mine in one + shell command, then check the log: an `OnchainTransactionReceived` line for the txid means the + sync saw the mempool first and the run tested the other path. +- The background journey needs background payments enabled (Settings > Notifications). +- Every event the node emits is logged by `LightningService` as `LDK event fired: ` under the + `APP` logcat tag, so `adb logcat -d -s APP:V` is enough to tell the two paths apart. +- The Receive sheet's tabs carry no test tag; select the Savings tab by its label. diff --git a/journeys/onchain-receive/confirmed-only-background-notification.xml b/journeys/onchain-receive/confirmed-only-background-notification.xml new file mode 100644 index 0000000000..a5834da7fb --- /dev/null +++ b/journeys/onchain-receive/confirmed-only-background-notification.xml @@ -0,0 +1,22 @@ + + + Covers issue #797 on the LightningNodeService path. With background payments enabled and the app + in the background, a deposit first seen already confirmed must post exactly one "Payment + Received" notification. + + Precondition: onboarded regtest wallet with background payments enabled, so the "Bitkit is + running in background" foreground service notification is present. + + + Tap Receive (testTag "Receive") and verify the Receive sheet opens (testTag "ReceiveScreen") + Tap the "Savings" receive tab (the tab row carries no test tag), tap "Show Details" (testTag "ShowDetails") and read the address from testTag "ReceiveOnchainAddress" + Press back, then send the app to the background: adb shell input keyevent KEYCODE_HOME + Run in one command: ./lsp POST /regtest/chain/deposit '{"address":"<savings addr>","amountSat":7970}' && ./lsp POST /regtest/chain/mine '{"count":1}' + Wait 40s + Run: adb shell dumpsys notification --noredact | grep -E "android\.title=String \(Payment Received\)|android\.text=String \(Received " + Verify exactly one "android.title=String (Payment Received)" line is printed, and an "android.text=String (Received <amount>)" line with it carrying the fiat and BTC amounts in the order set by the primary display setting. The foreground-service notification is titled "Bitkit Regtest" on the dev flavour, so it does not match either pattern + Open the notification shade and tap the "Payment Received" notification + Verify the app opens with the received sheet (testTag "ReceivedTransaction") showing the deposited amount + Tap the sheet button (testTag "ReceivedTransactionButton") and verify the home screen shows with no second sheet + + diff --git a/journeys/onchain-receive/confirmed-only-received-sheet.xml b/journeys/onchain-receive/confirmed-only-received-sheet.xml new file mode 100644 index 0000000000..7f4dbc286f --- /dev/null +++ b/journeys/onchain-receive/confirmed-only-received-sheet.xml @@ -0,0 +1,25 @@ + + + Covers issue #797. An onchain deposit the wallet first sees already confirmed, with no prior + mempool event, must show the received sheet once. ldk-node emits OnchainTransactionConfirmed + without OnchainTransactionReceived in that case. + + Precondition: onboarded regtest wallet, node running, app in the foreground on the home screen. + The deposit and the mine must run in one shell command so the 10s wallet sync does not see the + transaction in the mempool first. If the log shows OnchainTransactionReceived for the txid, the + run tested the mempool path instead; repeat with a new address. + + + Tap Receive (testTag "Receive") and verify the Receive sheet opens (testTag "ReceiveScreen") + Tap the "Savings" receive tab (the tab row carries no test tag), tap "Show Details" (testTag "ShowDetails") and read the address from testTag "ReceiveOnchainAddress" + Press back to return to the home screen + Run: adb logcat -c + Run in one command: ./lsp POST /regtest/chain/deposit '{"address":"<savings addr>","amountSat":21797}' && ./lsp POST /regtest/chain/mine '{"count":1}' + Wait up to 30s for the next wallet sync + Run: adb logcat -d -s APP:V | grep <txid> + Verify the log shows an "LDK event fired" line with OnchainTransactionConfirmed for the txid and no OnchainTransactionReceived for it + Verify the received sheet (testTag "ReceivedTransaction") is visible with the deposited amount (testTag "MoneyText") + Tap the sheet button (testTag "ReceivedTransactionButton") + Wait 10s and verify the received sheet does not appear again + + diff --git a/journeys/onchain-receive/mempool-then-confirmed-single-sheet.xml b/journeys/onchain-receive/mempool-then-confirmed-single-sheet.xml new file mode 100644 index 0000000000..6f912d135a --- /dev/null +++ b/journeys/onchain-receive/mempool-then-confirmed-single-sheet.xml @@ -0,0 +1,21 @@ + + + Covers issue #797. Confirmed events now reach the received-payment handler, so a deposit seen + in the mempool first must not show a second sheet when it confirms. The handler dedupes on the + txid and on the persisted seen state. + + Precondition: onboarded regtest wallet, node running, app in the foreground on the home screen. + + + Tap Receive (testTag "Receive") and verify the Receive sheet opens (testTag "ReceiveScreen") + Tap the "Savings" receive tab (the tab row carries no test tag), tap "Show Details" (testTag "ShowDetails") and read the address from testTag "ReceiveOnchainAddress" + Press back to return to the home screen + Run: ./lsp POST /regtest/chain/deposit '{"address":"<savings addr>","amountSat":14797}' + Wait up to 30s and verify the received sheet (testTag "ReceivedTransaction") is visible with the deposited amount + Tap the sheet button (testTag "ReceivedTransactionButton") + Run: ./lsp POST /regtest/chain/mine '{"count":1}' + Run: adb logcat -d -s APP:V | grep <txid> and wait until an "LDK event fired" line shows OnchainTransactionConfirmed + Wait 30s after that event and verify the received sheet does not appear again + Verify no "Payment Received" notification is posted: adb shell dumpsys notification --noredact | grep "Payment Received" + +