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 @@ -17,6 +17,7 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.lightningdevkit.ldknode.Event
import to.bitkit.App
import to.bitkit.R
Expand Down Expand Up @@ -107,10 +108,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
val presented = withContext(uiDispatcher) {
if (!notifyPaymentReceivedHandler.claimPresentation(command) { App.currentActivity?.value == null }) {
return@withContext false
}
showPaymentNotification(result.sheet, result.notification)
true
}
if (presented) notifyPaymentReceivedHandler.recordPresentation(command)
}
}

Expand All @@ -133,10 +141,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)
Expand Down
22 changes: 19 additions & 3 deletions app/src/main/java/to/bitkit/data/CacheStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,27 @@ 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 cacheBalance(balanceState: BalanceState) {
store.updateData { it.copy(balance = balanceState) }
}
Expand Down Expand Up @@ -123,6 +136,7 @@ data class AppCacheData(
val paidOrders: Map<String, String> = mapOf(),
val onchainAddress: String = "",
val bolt11: String = "",
val bolt11PaymentHash: String = "",
val bip21: String = "",
val balance: BalanceState? = null,
val backupStatuses: Map<BackupCategory, BackupItemStatus> = mapOf(),
Expand All @@ -132,5 +146,7 @@ data class AppCacheData(
val addressSearchLastUsedReceiveIndexes: Map<String, Int> = mapOf(),
val addressSearchLastUsedChangeIndexes: Map<String, Int> = mapOf(),
) {
fun resetBip21() = copy(bip21 = "", bolt11 = "", onchainAddress = "")
fun resetBip21() = copy(bip21 = "", bolt11 = "", bolt11PaymentHash = "", onchainAddress = "")

fun invalidateReceiveLightningInvoice() = copy(bip21 = "", bolt11 = "", bolt11PaymentHash = "")
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -19,16 +20,21 @@ class NotifyPaymentReceivedHandler @Inject constructor(
private val activityRepo: ActivityRepo,
private val receivedNotificationContent: ReceivedNotificationContent,
) {
private val presentationClaimsLock = Any()
private val presentationClaims = mutableSetOf<String>()

suspend operator fun invoke(
command: NotifyPaymentReceived.Command,
): Result<NotifyPaymentReceived.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)

Expand All @@ -43,12 +49,39 @@ 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 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 {
Expand All @@ -60,12 +93,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
Expand Down
66 changes: 62 additions & 4 deletions app/src/main/java/to/bitkit/repositories/LightningRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,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
Expand All @@ -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
Expand All @@ -73,6 +75,7 @@ import to.bitkit.models.NATIVE_WITNESS_TYPES
import to.bitkit.models.NodeLifecycleState
import to.bitkit.models.OpenChannelResult
import to.bitkit.models.TransactionSpeed
import to.bitkit.models.WalletScope
import to.bitkit.models.safe
import to.bitkit.models.satsToMsat
import to.bitkit.models.toAddressType
Expand All @@ -88,7 +91,6 @@ import to.bitkit.services.LnurlWithdrawResponse
import to.bitkit.services.LspNotificationsService
import to.bitkit.services.NodeEventHandler
import to.bitkit.utils.AppError
import to.bitkit.models.WalletScope
import to.bitkit.utils.Logger
import to.bitkit.utils.ServiceError
import to.bitkit.utils.UrlValidator
Expand Down Expand Up @@ -125,8 +127,9 @@ class LightningRepo @Inject constructor(
private val _lightningState = MutableStateFlow(LightningState())
val lightningState = _lightningState.asStateFlow()

private val _nodeEvents = MutableSharedFlow<Event>(extraBufferCapacity = 64)
val nodeEvents = _nodeEvents.asSharedFlow()
private val _nodeEventUpdates = MutableSharedFlow<NodeEventUpdate>(extraBufferCapacity = 64)
val nodeEventUpdates = _nodeEventUpdates.asSharedFlow()
val nodeEvents = nodeEventUpdates.map { it.event }

private val scope = CoroutineScope(bgDispatcher + SupervisorJob())

Expand Down Expand Up @@ -439,10 +442,55 @@ class LightningRepo @Inject constructor(
private suspend fun onEvent(event: Event) {
handleLdkEvent(event)
recordProbeOutcome(event)
val settledReceiveInvoice = if (event is Event.PaymentReceived) {
val settledInvoice = invalidateSettledReceiveInvoice(event)
val paymentId = event.paymentId ?: event.paymentHash
runSuspendCatching { coreService.activity.handlePaymentEvent(paymentId) }
.onFailure { Logger.error("Failed to record received payment '$paymentId'", it, context = TAG) }
settledInvoice
} else {
null
}
_eventHandlers.toList().forEach {
runCatching { it.invoke(event) }
}
_nodeEvents.emit(event)
_nodeEventUpdates.emit(
NodeEventUpdate(
event = event,
settledReceiveInvoice = settledReceiveInvoice,
),
)
}

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
}
}

fun setRecoveryMode(enabled: Boolean) = _isRecoveryMode.update { enabled }
Expand Down Expand Up @@ -1729,6 +1777,16 @@ 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,
)

data class SettledReceiveInvoice(
val bolt11: String,
)

sealed class ProbeError(message: String) : AppError(message) {
class NoProbeHandles : ProbeError("No probe handles returned")
class TimedOut : ProbeError("Probe timed out")
Expand Down
Loading
Loading