Skip to content
11 changes: 11 additions & 0 deletions app/src/main/java/to/bitkit/data/CacheStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,15 @@ class CacheStore internal constructor(
}
}

suspend fun setPendingLightningMessage(paymentHash: String, message: String) {
store.updateData { it.copy(pendingLightningMessages = it.pendingLightningMessages + (paymentHash to message)) }
}

suspend fun removePendingLightningMessage(paymentHash: String) {
if (paymentHash !in store.data.first().pendingLightningMessages) return
store.updateData { it.copy(pendingLightningMessages = it.pendingLightningMessages - paymentHash) }
}

suspend fun setBackgroundReceive(details: NewTransactionSheetDetails) = store.updateData {
it.copy(backgroundReceive = details)
}
Expand Down Expand Up @@ -168,6 +177,8 @@ data class AppCacheData(
val addressSearchLastUsedChangeIndexes: Map<String, Int> = mapOf(),
val quickPayLedger: QuickPayLedger? = null,
val blocktankRefundAddress: BlocktankRefundAddress? = null,
/** LNURL-pay comments by payment hash, kept until the sent payment's activity stores them. */
val pendingLightningMessages: Map<String, String> = mapOf(),
) {
fun isActivityDeleted(activityId: String, walletId: String): Boolean =
scopedActivityId(walletId, activityId) in deletedActivities ||
Expand Down
36 changes: 36 additions & 0 deletions app/src/main/java/to/bitkit/repositories/ActivityRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,42 @@ class ActivityRepo @Inject constructor(
is Activity.Onchain -> Activity.Onchain(v1.copy(contact = normalizedKey, updatedAt = updatedAt))
}

suspend fun savePendingLightningMessage(
paymentHash: String,
message: String,
): Result<Unit> = withContext(bgDispatcher) {
runSuspendCatching {
cacheStore.setPendingLightningMessage(paymentHash, message)
}.onFailure {
Logger.error("Failed to save pending message for payment '$paymentHash'", it, context = TAG)
}
}

suspend fun clearPendingLightningMessage(paymentHash: String): Result<Unit> = withContext(bgDispatcher) {
runSuspendCatching {
cacheStore.removePendingLightningMessage(paymentHash)
}.onFailure {
Logger.error("Failed to clear pending message for payment '$paymentHash'", it, context = TAG)
}
}

/**
* Stores [message] on the Lightning activity for [paymentHash] unless it already holds a note.
*
* The pending message is kept for the payment sync when the activity does not exist yet.
*/
suspend fun setLightningMessageIfEmpty(
paymentHash: String,
message: String,
): Result<Unit> = withContext(bgDispatcher) {
runSuspendCatching {
coreService.activity.setLightningMessageIfEmpty(paymentHash, message)
notifyActivitiesChanged()
}.onFailure {
Logger.error("Failed to set message for payment '$paymentHash'", it, context = TAG)
}
}

suspend fun getClosedChannels(
sortDirection: SortDirection = SortDirection.ASC,
): Result<List<ClosedChannelDetails>> = withContext(bgDispatcher) {
Expand Down
43 changes: 42 additions & 1 deletion app/src/main/java/to/bitkit/services/CoreService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,22 @@ internal fun LightningActivity.withPaymentUpdate(
contact = contact,
)

/**
* Applies a pending LNURL-pay comment as the stored Lightning activity message.
*
* The comment replaces only an empty message or the [description] LDK reported, which for a
* description-hash invoice is the hash itself. A comment is stored only for invoices without a
* direct description, so an invoice's own description is never replaced.
*/
internal fun LightningActivity.withPendingMessage(
pendingMessage: String?,
description: String?,
): LightningActivity {
if (pendingMessage.isNullOrBlank()) return this
if (message.isNotEmpty() && message != description) return this
return copy(message = pendingMessage)
}

@Suppress("LargeClass", "TooManyFunctions")
class ActivityService(
@Suppress("unused") private val coreService: CoreService, // used to ensure CoreService inits first
Expand Down Expand Up @@ -748,6 +764,9 @@ class ActivityService(
return
}

val pendingMessage = payment.id.takeIf { payment.direction == PaymentDirection.OUTBOUND }
?.let { cacheStore.data.first().pendingLightningMessages[it] }

val existingActivity = getActivityById(walletId = defaultWalletId, activityId = payment.id)
if (existingActivity is Activity.Lightning) {
val statusChanging = existingActivity.v1.status != state
Expand Down Expand Up @@ -787,13 +806,35 @@ class ActivityService(
contact = contact,
seenAt = null,
)
}
}.withPendingMessage(pendingMessage = pendingMessage, description = kind.description)
Comment thread
jvsena42 marked this conversation as resolved.

if (getActivityById(walletId = defaultWalletId, activityId = payment.id) != null) {
updateActivity(activityId = payment.id, activity = Activity.Lightning(ln))
} else {
upsertActivity(Activity.Lightning(ln))
}

if (pendingMessage != null) cacheStore.removePendingLightningMessage(payment.id)
}

/**
* Applies a pending LNURL-pay comment to the Lightning activity for [paymentHash] if the row exists.
*
* The row is read and written with no suspension point in between, so on the single-threaded Core
* queue no payment sync can write the same row from a stale snapshot. The pending comment is kept
* when the row does not exist yet, so the payment sync applies it later.
*/
suspend fun setLightningMessageIfEmpty(paymentHash: String, message: String) = ServiceQueue.CORE.background {
val description = lightningService.listPayments()
?.firstOrNull { it.id == paymentHash }
?.let { (it.kind as? PaymentKind.Bolt11)?.description }
val existing = getActivityById(walletId = defaultWalletId, activityId = paymentHash)
as? Activity.Lightning ?: return@background
val updated = existing.v1.withPendingMessage(pendingMessage = message, description = description)
if (updated != existing.v1) {
updateActivity(activityId = paymentHash, activity = Activity.Lightning(updated))
}
cacheStore.removePendingLightningMessage(paymentHash)
}

/**
Expand Down
13 changes: 13 additions & 0 deletions app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4009,6 +4009,8 @@ class AppViewModel @Inject constructor(
}
}

val lnurlComment = savePendingLnurlComment(decodedInvoice, paymentHash)

sendLightning(decodedInvoice.bolt11, paymentAmount).onSuccess { actualPaymentHash ->
proofRequest = null
Logger.info("Lightning send result payment hash: $actualPaymentHash", context = TAG)
Expand All @@ -4020,6 +4022,7 @@ class AppViewModel @Inject constructor(
sats = displayAmountSats.toLong(),
),
)
lnurlComment?.let { activityRepo.setLightningMessageIfEmpty(paymentHash, it) }
}.onFailure { error ->
if (!clearFailedLightningPayment(paymentHash, error, incomingPaymentRequest != null)) {
val pendingHash = (error as? PaymentPendingException)?.paymentHash ?: paymentHash
Expand All @@ -4029,10 +4032,12 @@ class AppViewModel @Inject constructor(
preserveContactPaymentContext(pendingHash)
refreshIncomingPaykitPaymentRequests()
setSendEffect(SendEffect.NavigateToPending(pendingHash, displayAmountSats.toLong()))
lnurlComment?.let { activityRepo.setLightningMessageIfEmpty(paymentHash, it) }
return@onFailure
}
cancelPaymentProofPreparation(proofRequest)
createdMetadataPaymentId?.let { preActivityMetadataRepo.deletePreActivityMetadata(it) }
lnurlComment?.let { activityRepo.clearPendingLightningMessage(paymentHash) }
Logger.error("Error sending lightning payment", error, context = TAG)
val failure = when (error) {
is LightningPaymentFailedError -> error.reason.toSendFailureDetails(context, error.paymentRequest)
Expand All @@ -4042,6 +4047,14 @@ class AppViewModel @Inject constructor(
}
}

private suspend fun savePendingLnurlComment(invoice: LightningInvoice, paymentHash: String): String? {
val state = _sendUiState.value
if (state.lnurl !is LnurlParams.LnurlPay || state.comment.isBlank()) return null
if (!invoice.description.isNullOrEmpty()) return null
activityRepo.savePendingLightningMessage(paymentHash, state.comment)
return state.comment
}

private suspend fun clearFailedLightningPayment(
paymentHash: String,
error: Throwable,
Expand Down
35 changes: 35 additions & 0 deletions app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import org.mockito.kotlin.any
import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.argThat
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.doSuspendableAnswer
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
Expand Down Expand Up @@ -1485,6 +1486,40 @@ class ActivityRepoTest : BaseUnitTest() {
verify(cacheStore).removeActivityFromPendingBoost(pendingBoost)
}

@Test
fun `savePendingLightningMessage stores the message by payment hash`() = test {
val result = sut.savePendingLightningMessage("payment-hash", "thanks")

assertTrue(result.isSuccess)
verify(cacheStore).setPendingLightningMessage("payment-hash", "thanks")
}

@Test
fun `clearPendingLightningMessage removes the message for the payment hash`() = test {
val result = sut.clearPendingLightningMessage("payment-hash")

assertTrue(result.isSuccess)
verify(cacheStore).removePendingLightningMessage("payment-hash")
}

@Test
fun `setLightningMessageIfEmpty delegates to the activity service`() = test {
val result = sut.setLightningMessageIfEmpty("payment-hash", "thanks")

assertTrue(result.isSuccess)
verify(coreService.activity).setLightningMessageIfEmpty("payment-hash", "thanks")
}

@Test
fun `setLightningMessageIfEmpty returns failure when the activity service fails`() = test {
whenever(coreService.activity.setLightningMessageIfEmpty("payment-hash", "thanks"))
.doSuspendableAnswer { throw AppError("db") }

val result = sut.setLightningMessageIfEmpty("payment-hash", "thanks")

assertTrue(result.isFailure)
}

private companion object {
const val HARDWARE_WALLET_ID = "trezor:abc123"
}
Expand Down
Loading
Loading