Skip to content
Merged
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
20 changes: 18 additions & 2 deletions app/src/main/java/to/bitkit/data/SettingsStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,11 @@ class SettingsStore @Inject constructor(
.copy(ignoresSwitchUnitToast = false, ignoresHideBalanceToast = false)
.withDefaultPaykitPaymentMethods()
.withRequiredNativeSegwitMonitoring()
store.updateData { data }
store.updateData { current ->
// The received-sheet hold is armed before the backup is read, so it has to survive the
// settings the backup brings with it - otherwise the replayed history raises sheets.
data.copy(pendingRestoreActivitySeenSince = current.pendingRestoreActivitySeenSince)
}

val monitored = data.addressTypesToMonitor
val selected = data.selectedAddressType
Expand Down Expand Up @@ -198,7 +202,19 @@ data class SettingsData(
val selectedAddressType: String = DEFAULT_ADDRESS_TYPE_STRING,
val addressTypesToMonitor: List<String> = listOf(DEFAULT_ADDRESS_TYPE_STRING),
val pendingRestoreAddressTypePrune: Boolean = false,
)
/**
* When a seed restore began, as epoch seconds, or 0 when no restore is being suppressed.
*
* Suppresses the on-chain received sheet for the historical transactions the post-restore sync replays. Set as
* the restore starts, before the node is started, because the node syncs long before the backup is read - setting
* it on the Get Started tap left a window where replayed transactions could still raise a sheet. Doubles as the
* cutoff for the sweep that marks those transactions seen, so a payment arriving mid-restore is not swept up with
* them. Cleared by the first on-chain sync completion whose sweep succeeds.
*/
val pendingRestoreActivitySeenSince: Long = 0,
) {
val pendingRestoreActivitySeen: Boolean get() = pendingRestoreActivitySeenSince > 0
}

data class BalanceUnitSwitch(
val previousDisplay: PrimaryDisplay,
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 @@ -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()
Expand Down Expand Up @@ -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 {
Expand All @@ -96,25 +112,65 @@ 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) {
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 +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
},
)

Expand All @@ -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
}
}
4 changes: 2 additions & 2 deletions app/src/main/java/to/bitkit/repositories/ActivityRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1059,9 +1059,9 @@ class ActivityRepo @Inject constructor(
return@withContext Result.success(Unit)
}

suspend fun markAllUnseenActivitiesAsSeen(): Result<Unit> = withContext(bgDispatcher) {
suspend fun markAllUnseenActivitiesAsSeen(startedBefore: ULong? = null): Result<Unit> = withContext(bgDispatcher) {
runCatching {
coreService.activity.markAllUnseenActivitiesAsSeen()
coreService.activity.markAllUnseenActivitiesAsSeen(startedBefore)
notifyActivitiesChanged()
}.onFailure {
Logger.error("Failed to mark all activities as seen: $it", it, context = TAG)
Expand Down
25 changes: 17 additions & 8 deletions app/src/main/java/to/bitkit/services/CoreService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ import com.synonym.bitkitcore.TxInput as BitkitCoreTxInput
import com.synonym.bitkitcore.TxOutput as BitkitCoreTxOutput
import com.synonym.bitkitcore.getLnurlInvoiceForPayData as coreGetLnurlInvoiceForPayData
import com.synonym.bitkitcore.getTransactionDetails as getBitkitCoreTransactionDetails
import com.synonym.bitkitcore.markActivityAsSeen as coreMarkActivityAsSeen

// region Core

Expand Down Expand Up @@ -1695,18 +1696,13 @@ class ActivityService(
walletId: String = defaultWalletId,
seenAt: ULong? = null,
) = ServiceQueue.CORE.background {
val activity = getActivityById(walletId = walletId, activityId = activityId) ?: run {
if (getActivityById(walletId = walletId, activityId = activityId) == null) {
Logger.warn("Cannot mark activity as seen - activity not found: $activityId", context = TAG)
return@background
}

val timestamp = seenAt ?: nowTimestamp().epochSecond.toULong()
val updatedActivity = when (activity) {
is Activity.Lightning -> Activity.Lightning(activity.v1.copy(seenAt = timestamp))
is Activity.Onchain -> Activity.Onchain(activity.v1.copy(seenAt = timestamp))
}

updateActivity(activityId = activityId, activity = updatedActivity)
coreMarkActivityAsSeen(walletId = walletId, activityId = activityId, seenAt = timestamp)
Logger.info("Marked activity $activityId as seen at $timestamp", context = TAG)
}

Expand All @@ -1724,7 +1720,14 @@ class ActivityService(
markActivityAsSeen(activity.id, walletId = activity.walletId, seenAt = seenAt)
}

suspend fun markAllUnseenActivitiesAsSeen() = ServiceQueue.CORE.background {
/**
* Marks every unseen activity as seen.
*
* [startedBefore] limits the pass to activity that already existed at that epoch second. The restore sweep passes
* the moment the restore began, so a payment that genuinely arrives while the restore is still running keeps its
* unseen state and still notifies the user.
*/
suspend fun markAllUnseenActivitiesAsSeen(startedBefore: ULong? = null) = ServiceQueue.CORE.background {
val timestamp = nowTimestamp().epochSecond.toULong()
val activities = getActivities(
walletId = null,
Expand All @@ -1743,6 +1746,12 @@ class ActivityService(
is Activity.Onchain -> activity.v1.seenAt != null
is Activity.Lightning -> activity.v1.seenAt != null
}
val createdAt = when (activity) {
is Activity.Onchain -> activity.v1.timestamp
is Activity.Lightning -> activity.v1.timestamp
}

if (startedBefore != null && createdAt > startedBefore) continue

if (!isSeen) {
markActivityAsSeen(activity.rawId(), walletId = activity.walletId(), seenAt = timestamp)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,8 @@ internal object TrezorPreviewData {

val sampleWatcherActivities = listOf(
Activity.Onchain(
OnchainActivity.create(walletId = "wallet0",
OnchainActivity.create(
walletId = "wallet0",
id = SAMPLE_TXID,
txType = PaymentType.RECEIVED,
txId = SAMPLE_TXID,
Expand All @@ -328,7 +329,8 @@ internal object TrezorPreviewData {
),
),
Activity.Onchain(
OnchainActivity.create(walletId = "wallet0",
OnchainActivity.create(
walletId = "wallet0",
id = "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3",
txType = PaymentType.SENT,
txId = "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3",
Expand Down
18 changes: 16 additions & 2 deletions app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,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
Expand Down Expand Up @@ -1362,7 +1363,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 ->
Expand Down Expand Up @@ -1450,7 +1451,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()
Expand Down Expand Up @@ -1480,6 +1483,16 @@ class AppViewModel @Inject constructor(
}
}

private suspend fun completePendingRestoreActivitySeen() {
val restoreStartedAt = settingsStore.data.first().pendingRestoreActivitySeenSince
if (restoreStartedAt <= 0) return
Logger.info("Marking activities replayed by the first sync after restore as seen", context = TAG)
// Bounded by the restore start so a payment arriving mid-restore keeps its unseen state.
activityRepo.markAllUnseenActivitiesAsSeen(startedBefore = restoreStartedAt.toULong()).onSuccess {
settingsStore.update { settings -> settings.copy(pendingRestoreActivitySeenSince = 0) }
}
}

private suspend fun completeRNRemoteBackupRestore() {
val channelMigration = buildChannelMigrationIfAvailable()

Expand Down Expand Up @@ -1600,6 +1613,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
Loading
Loading