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