From 5b62a37c41ca49c8e2120f83786a3eb446f6a7e0 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 17 Sep 2026 13:50:18 -0300 Subject: [PATCH 1/5] fix: keep lnurl pay comment as activity note Co-Authored-By: Claude Opus 5 (1M context) --- .../main/java/to/bitkit/data/CacheStore.kt | 11 ++ .../to/bitkit/repositories/ActivityRepo.kt | 36 ++++++ .../java/to/bitkit/services/CoreService.kt | 42 ++++++- .../java/to/bitkit/viewmodels/AppViewModel.kt | 13 +++ .../bitkit/repositories/ActivityRepoTest.kt | 35 ++++++ .../to/bitkit/services/CoreServiceTest.kt | 36 ++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 110 ++++++++++++++++++ changelog.d/next/417.fixed.md | 1 + 8 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 changelog.d/next/417.fixed.md diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index f45f5e3cdd..949f639d44 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -132,6 +132,15 @@ class CacheStore @Inject 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) } @@ -167,6 +176,8 @@ data class AppCacheData( val addressSearchLastUsedChangeIndexes: Map = 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 = mapOf(), ) { fun isActivityDeleted(activityId: String, walletId: String): Boolean = scopedActivityId(walletId, activityId) in deletedActivities || diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index ac6f4ce4b6..4cb9297540 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -580,6 +580,42 @@ class ActivityRepo @Inject constructor( is Activity.Onchain -> Activity.Onchain(v1.copy(contact = normalizedKey, updatedAt = updatedAt)) } + suspend fun savePendingLightningMessage( + paymentHash: String, + message: String, + ): Result = 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 = 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 = 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> = withContext(bgDispatcher) { diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index cc81b25586..83aa19897b 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -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 @@ -766,6 +782,9 @@ class ActivityService( ?.let { (it as Activity.Lightning).v1.contact } ?: privatePaykitContactPublicKeyForReceivedInvoicePaymentHash(payment.id, payment.direction) + val pendingMessage = payment.id.takeIf { payment.direction == PaymentDirection.OUTBOUND } + ?.let { cacheStore.data.first().pendingLightningMessages[it] } + val ln = if (existingActivity is Activity.Lightning) { existingActivity.v1.withPaymentUpdate( payment = payment, @@ -787,13 +806,34 @@ class ActivityService( contact = contact, seenAt = null, ) - } + }.withPendingMessage(pendingMessage = pendingMessage, description = kind.description) 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. + * + * Runs on the Core queue so it cannot interleave with the payment sync writing the same row. 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 existing = getActivityById(walletId = defaultWalletId, activityId = paymentHash) + as? Activity.Lightning ?: return@background + val description = lightningService.listPayments() + ?.firstOrNull { it.id == paymentHash } + ?.let { (it.kind as? PaymentKind.Bolt11)?.description } + val updated = existing.v1.withPendingMessage(pendingMessage = message, description = description) + if (updated != existing.v1) { + updateActivity(activityId = paymentHash, activity = Activity.Lightning(updated)) + } + cacheStore.removePendingLightningMessage(paymentHash) } /** diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index c912066536..eedf8e23ed 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -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) @@ -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 @@ -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) @@ -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, diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index f9413c2868..c653e5eea7 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -19,6 +19,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 @@ -1256,6 +1257,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" } diff --git a/app/src/test/java/to/bitkit/services/CoreServiceTest.kt b/app/src/test/java/to/bitkit/services/CoreServiceTest.kt index a5b59b755e..0a9bc7c9c9 100644 --- a/app/src/test/java/to/bitkit/services/CoreServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/CoreServiceTest.kt @@ -296,6 +296,42 @@ class CoreServiceTest { assertEquals("coffee", result.message) } + @Test + fun `pending message fills an empty message`() { + val result = failedSend(message = "").withPendingMessage(pendingMessage = "thanks", description = null) + + assertEquals("thanks", result.message) + } + + @Test + fun `pending message replaces the description hash reported by LDK`() { + val descriptionHash = "a".repeat(64) + val created = failedSend(message = descriptionHash) + + val result = created.withPendingMessage(pendingMessage = "thanks", description = descriptionHash) + + assertEquals("thanks", result.message) + } + + @Test + fun `pending message keeps a stored message that differs from the description`() { + val result = failedSend(message = "coffee").withPendingMessage( + pendingMessage = "thanks", + description = "a".repeat(64), + ) + + assertEquals("coffee", result.message) + } + + @Test + fun `missing pending message keeps the stored message`() { + val descriptionHash = "a".repeat(64) + val created = failedSend(message = descriptionHash) + + assertEquals(created, created.withPendingMessage(pendingMessage = null, description = descriptionHash)) + assertEquals(created, created.withPendingMessage(pendingMessage = " ", description = descriptionHash)) + } + private fun mergePlan( existing: List, incoming: List, diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 829cd2bf29..31341eb6fa 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -6493,6 +6493,87 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo, never()).discardRemoteLightningEndpoints(any(), any()) } + @Test + fun `LNURL pay comment is saved and applied as the activity note`() = test { + val paymentHash = "010203" + val bolt11 = "lnbcrt1lnurlcomment" + stubLnurlPayment(bolt11 = bolt11, description = null, payResult = Result.success(paymentHash)) + + sut.setSendEvent(SendEvent.PayConfirmed) + advanceUntilIdle() + emitNodeEvent( + Event.PaymentSuccessful( + paymentId = "payment_id", + paymentHash = paymentHash, + paymentPreimage = "preimage", + feePaidMsat = 10uL, + ), + ) + advanceUntilIdle() + + inOrder(activityRepo, lightningRepo) { + verify(activityRepo).savePendingLightningMessage(paymentHash, "thanks") + verify(lightningRepo).payInvoice(bolt11 = bolt11, sats = 1_000uL) + verify(activityRepo).setLightningMessageIfEmpty(paymentHash, "thanks") + } + verify(activityRepo, never()).clearPendingLightningMessage(any()) + } + + @Test + fun `LNURL pay comment is applied when the payment is pending`() = test { + val paymentHash = "010203" + val bolt11 = "lnbcrt1lnurlpending" + stubLnurlPayment( + bolt11 = bolt11, + description = null, + payResult = Result.failure(PaymentPendingException(paymentHash)), + ) + + sut.setSendEvent(SendEvent.PayConfirmed) + advanceUntilIdle() + + verify(activityRepo).savePendingLightningMessage(paymentHash, "thanks") + verify(activityRepo).setLightningMessageIfEmpty(paymentHash, "thanks") + verify(activityRepo, never()).clearPendingLightningMessage(any()) + } + + @Test + fun `LNURL pay comment does not replace the invoice description`() = test { + val paymentHash = "010203" + val bolt11 = "lnbcrt1lnurldescription" + stubLnurlPayment(bolt11 = bolt11, description = "Invoice description", payResult = Result.success(paymentHash)) + + sut.setSendEvent(SendEvent.PayConfirmed) + advanceUntilIdle() + emitNodeEvent( + Event.PaymentSuccessful( + paymentId = "payment_id", + paymentHash = paymentHash, + paymentPreimage = "preimage", + feePaidMsat = 10uL, + ), + ) + advanceUntilIdle() + + verify(lightningRepo).payInvoice(bolt11 = bolt11, sats = 1_000uL) + verify(activityRepo, never()).savePendingLightningMessage(any(), any()) + verify(activityRepo, never()).setLightningMessageIfEmpty(any(), any()) + } + + @Test + fun `failed LNURL payment clears the pending comment`() = test { + val paymentHash = "010203" + val bolt11 = "lnbcrt1lnurlfailed" + stubLnurlPayment(bolt11 = bolt11, description = null, payResult = Result.failure(AppError("boom"))) + + sut.setSendEvent(SendEvent.PayConfirmed) + advanceUntilIdle() + + verify(activityRepo).savePendingLightningMessage(paymentHash, "thanks") + verify(activityRepo).clearPendingLightningMessage(paymentHash) + verify(activityRepo, never()).setLightningMessageIfEmpty(any(), any()) + } + @Test fun `channel ready refreshes public Paykit endpoints when sharing enabled`() = test { enablePublicPaykitSharing() @@ -6850,6 +6931,35 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } } + private suspend fun stubLnurlPayment(bolt11: String, description: String?, payResult: Result) { + val data = LnurlPayData( + uri = "lnurl1comment", + callback = "https://example.com/callback", + minSendable = 1_000uL, + maxSendable = 100_000_000uL, + metadataStr = "[]", + commentAllowed = 100u, + allowsNostr = false, + nostrPubkey = null, + ) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + whenever { lightningRepo.fetchLnurlInvoice(data, 1_000_000uL, "thanks") } + .thenReturn(Result.success(lightningInvoice(bolt11, amountSats = 0uL).copy(description = description))) + whenever { lightningRepo.payInvoice(bolt11 = bolt11, sats = 1_000uL) }.thenReturn(payResult) + whenever { activityRepo.savePendingLightningMessage(any(), any()) }.thenReturn(Result.success(Unit)) + whenever { activityRepo.setLightningMessageIfEmpty(any(), any()) }.thenReturn(Result.success(Unit)) + whenever { activityRepo.clearPendingLightningMessage(any()) }.thenReturn(Result.success(Unit)) + setSendState( + SendUiState( + address = data.uri, + amount = 1_000uL, + payMethod = SendMethod.LIGHTNING, + lnurl = LnurlParams.LnurlPay(data), + comment = "thanks", + ), + ) + } + private suspend fun stubLightningScan(bolt11: String, amountSats: ULong) { whenever { coreService.decode(bolt11) } .thenReturn(Scanner.Lightning(lightningInvoice(bolt11, amountSats))) diff --git a/changelog.d/next/417.fixed.md b/changelog.d/next/417.fixed.md new file mode 100644 index 0000000000..40028131da --- /dev/null +++ b/changelog.d/next/417.fixed.md @@ -0,0 +1 @@ +Comments added when paying an LNURL-pay request are now saved as the payment note in the activity. From 05e18eabe25b897b887c7e2653325ea4a873be05 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 17 Sep 2026 14:02:04 -0300 Subject: [PATCH 2/5] fix: avoid stale row writes when storing lnurl comment Co-Authored-By: Claude Opus 5 (1M context) --- .../java/to/bitkit/services/CoreService.kt | 15 +- .../ActivityServiceLightningMessageTest.kt | 168 ++++++++++++++++++ 2 files changed, 176 insertions(+), 7 deletions(-) create mode 100644 app/src/test/java/to/bitkit/services/ActivityServiceLightningMessageTest.kt diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 83aa19897b..be7a937479 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -764,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 @@ -782,9 +785,6 @@ class ActivityService( ?.let { (it as Activity.Lightning).v1.contact } ?: privatePaykitContactPublicKeyForReceivedInvoicePaymentHash(payment.id, payment.direction) - val pendingMessage = payment.id.takeIf { payment.direction == PaymentDirection.OUTBOUND } - ?.let { cacheStore.data.first().pendingLightningMessages[it] } - val ln = if (existingActivity is Activity.Lightning) { existingActivity.v1.withPaymentUpdate( payment = payment, @@ -820,15 +820,16 @@ class ActivityService( /** * Applies a pending LNURL-pay comment to the Lightning activity for [paymentHash] if the row exists. * - * Runs on the Core queue so it cannot interleave with the payment sync writing the same row. The - * pending comment is kept when the row does not exist yet, so the payment sync applies it later. + * 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 existing = getActivityById(walletId = defaultWalletId, activityId = paymentHash) - as? Activity.Lightning ?: return@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)) diff --git a/app/src/test/java/to/bitkit/services/ActivityServiceLightningMessageTest.kt b/app/src/test/java/to/bitkit/services/ActivityServiceLightningMessageTest.kt new file mode 100644 index 0000000000..a03424ee6c --- /dev/null +++ b/app/src/test/java/to/bitkit/services/ActivityServiceLightningMessageTest.kt @@ -0,0 +1,168 @@ +package to.bitkit.services + +import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.LightningActivity +import com.synonym.bitkitcore.PaymentState +import com.synonym.bitkitcore.PaymentType +import com.synonym.bitkitcore.getActivityById +import com.synonym.bitkitcore.updateActivity +import com.synonym.bitkitcore.upsertActivity +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import org.junit.Test +import org.lightningdevkit.ldknode.PaymentDetails +import org.lightningdevkit.ldknode.PaymentDirection +import org.lightningdevkit.ldknode.PaymentKind +import org.lightningdevkit.ldknode.PaymentStatus +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.async.ServiceQueue +import to.bitkit.data.AppCacheData +import to.bitkit.data.CacheStore +import to.bitkit.ext.create +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ActivityServiceLightningMessageTest : BaseUnitTest() { + companion object { + private const val BITKIT_CORE_FFI_CLASS = "com.synonym.bitkitcore.Bitkitcore_androidKt" + private const val HASH = "payment-hash" + private const val COMMENT = "thanks" + private val DESCRIPTION_HASH = "a".repeat(64) + } + + private val cacheStore = mock() + private val lightningService = mock() + + private var row: Activity? = null + + @Test + fun `set message does not write back a row the payment sync replaced meanwhile`() = coreTest { + row = Activity.Lightning(sent(status = PaymentState.PENDING, message = DESCRIPTION_HASH)) + val succeeded = sent(status = PaymentState.SUCCEEDED, message = DESCRIPTION_HASH, fee = 1uL, preimage = "pre") + whenever(lightningService.listPayments()).thenAnswer { + row = Activity.Lightning(succeeded) + listOf(payment()) + } + + sut().setLightningMessageIfEmpty(HASH, COMMENT) + + assertEquals(succeeded.copy(message = COMMENT), (row as Activity.Lightning).v1) + verify(cacheStore).removePendingLightningMessage(HASH) + } + + @Test + fun `set message keeps the pending comment when the row does not exist yet`() = coreTest { + whenever(lightningService.listPayments()).thenReturn(listOf(payment())) + + sut().setLightningMessageIfEmpty(HASH, COMMENT) + + assertNull(row) + verify(cacheStore, never()).removePendingLightningMessage(any()) + } + + @Test + fun `payment sync creates the row with the pending comment and clears it`() = coreTest { + whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(pendingLightningMessages = mapOf(HASH to COMMENT)))) + + sut().syncLdkNodePaymentsToActivities(listOf(payment())) + + val stored = (row as Activity.Lightning).v1 + assertEquals(COMMENT, stored.message) + assertEquals(PaymentState.SUCCEEDED, stored.status) + verify(cacheStore).removePendingLightningMessage(HASH) + } + + @Test + fun `payment sync replaces the description hash on an existing row with the pending comment`() = coreTest { + row = Activity.Lightning(sent(status = PaymentState.PENDING, message = DESCRIPTION_HASH)) + whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(pendingLightningMessages = mapOf(HASH to COMMENT)))) + + sut().syncLdkNodePaymentsToActivities(listOf(payment())) + + val stored = (row as Activity.Lightning).v1 + assertEquals(COMMENT, stored.message) + assertEquals(PaymentState.SUCCEEDED, stored.status) + verify(cacheStore).removePendingLightningMessage(HASH) + } + + @Test + fun `payment sync keeps a comment stored while it read the pending messages`() = coreTest { + row = Activity.Lightning(sent(status = PaymentState.PENDING, message = DESCRIPTION_HASH)) + val commentStoredMeanwhile: Flow = flow { + row = Activity.Lightning(sent(status = PaymentState.PENDING, message = COMMENT)) + emit(AppCacheData()) + } + whenever(cacheStore.data).thenReturn(commentStoredMeanwhile) + + sut().syncLdkNodePaymentsToActivities(listOf(payment())) + + val stored = (row as Activity.Lightning).v1 + assertEquals(COMMENT, stored.message) + assertEquals(PaymentState.SUCCEEDED, stored.status) + } + + private fun coreTest(block: suspend () -> Unit) = test { + ServiceQueue.CORE.background { + mockStatic(Class.forName(BITKIT_CORE_FFI_CLASS)).use { native -> + native.`when` { getActivityById(any(), any()) }.thenAnswer { row } + native.`when` { updateActivity(any(), any()) }.thenAnswer { + row = it.arguments[1] as Activity + null + } + native.`when` { upsertActivity(any()) }.thenAnswer { + row = it.arguments[0] as Activity + null + } + block() + } + } + } + + private fun sut() = ActivityService( + coreService = mock(), + cacheStore = cacheStore, + lightningService = lightningService, + settingsStore = mock(), + privatePaykitContactResolver = mock(), + ) + + private fun sent( + status: PaymentState, + message: String, + fee: ULong = 0uL, + preimage: String? = null, + ) = LightningActivity.create( + id = HASH, + txType = PaymentType.SENT, + status = status, + value = 22uL, + invoice = "invoice", + timestamp = 100uL, + fee = fee, + message = message, + preimage = preimage, + ) + + private fun payment() = PaymentDetails( + id = HASH, + kind = PaymentKind.Bolt11( + hash = HASH, + preimage = "pre", + secret = null, + description = DESCRIPTION_HASH, + bolt11 = "invoice", + ), + amountMsat = 22_000uL, + feePaidMsat = 1_000uL, + direction = PaymentDirection.OUTBOUND, + status = PaymentStatus.SUCCEEDED, + latestUpdateTimestamp = 200uL, + ) +} From 2a28e75751f91c77a09bbc6690a9ed64b1b1cbc4 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 18 Sep 2026 10:43:45 -0300 Subject: [PATCH 3/5] docs: add lnurl pay comment note journey Co-Authored-By: Claude Opus 5 (1M context) --- journeys/README.md | 2 + journeys/lnurl/lnurl-pay-comment-note.xml | 48 +++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 journeys/lnurl/lnurl-pay-comment-note.xml diff --git a/journeys/README.md b/journeys/README.md index 403c5e2309..af832fc7fb 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -118,6 +118,7 @@ fixtures, push notifications) live in each suite's README. | [cjit-notifications](cjit-notifications) | 3 | CJIT channel-ready notifications; needs FCM push | | [deeplinks](deeplinks) | 2 | `bitkit://screen/…` and sheet routing behind the dev-mode gate; no README | | [hardware-wallet](hardware-wallet) | 17 | Trezor over USB; needs the Trezor emulator | +| [lnurl](lnurl) | 1 | LNURL-pay comment kept as the activity note; needs an LNURL-pay endpoint that allows comments; no README | | [notification-permission](notification-permission) | 4 | Background-setup toggles | | [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 | @@ -142,6 +143,7 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `hardware-wallet/receive-onchain.xml`, `hardware-wallet/send-onchain.xml` | not ported | | `payment-requests/requested-resolution-failure.xml` | not ported | | `deeplinks/*` | not ported — iOS registers the `bitkit` scheme but has no screen or sheet router | +| `lnurl/lnurl-pay-comment-note.xml` | not ported — bitkit-ios has not been checked for keeping the LNURL-pay comment on the activity | | — | `hardware-wallet/transfer-to-spending-over-max.xml` exists only on iOS | ### Running one on iOS diff --git a/journeys/lnurl/lnurl-pay-comment-note.xml b/journeys/lnurl/lnurl-pay-comment-note.xml new file mode 100644 index 0000000000..8129529ac6 --- /dev/null +++ b/journeys/lnurl/lnurl-pay-comment-note.xml @@ -0,0 +1,48 @@ + + + Verifies that the comment typed when paying an LNURL-pay request is saved as the Invoice note of + the sent Lightning activity, and that it survives an app restart. Requires an LNURL-pay endpoint + that returns a description-hash invoice and allows comments (commentAllowed of at least 12), such + as the bitkit-docker lnurl-server, reachable from the same Lightning network as the app. The + staging regtest backend the dev flavor uses has no such endpoint. Also requires a spending balance + of at least 1,000 sats plus routing fees. + + + + Launch the Bitkit app and go to the wallet home screen + + + Run adb shell am start -a android.intent.action.VIEW -d "lightning:<lnurl-pay>" to.bitkit.dev with the LNURL-pay string + + + Verify the send amount screen (testTag "SendNumberField") is visible + + + Enter 1000 on the number pad (testTags "N1", "N0", "N0", "N0") and tap Continue (testTag "ContinueAmount") + + + Verify the comment field (testTag "CommentInput") is visible on the confirm screen + + + Type "thanks" into the comment field (testTag "CommentInput") + + + Swipe the pay slider (testTag "GRAB") to the right + + + Verify the payment success screen (testTag "SendSuccess") or the "Payment Pending" screen is visible — the pending screen carries no testTag, so assert it by its title + + + Close the result screen and open the newest activity row (testTag "ActivityShort-0") + + + Verify the Invoice note (testTag "InvoiceNote") shows "thanks" and not the invoice description hash + + + Run adb shell am force-stop to.bitkit.dev, relaunch the app and open the newest activity row (testTag "ActivityShort-0") + + + Verify the Invoice note (testTag "InvoiceNote") still shows "thanks" + + + From 7a011ab52294dd9789974ae345cfd41cc0903165 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 18 Sep 2026 11:09:19 -0300 Subject: [PATCH 4/5] docs: name a description-hash lnurl endpoint Co-Authored-By: Claude Opus 5 (1M context) --- journeys/lnurl/lnurl-pay-comment-note.xml | 26 +++++++++++++++++------ 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/journeys/lnurl/lnurl-pay-comment-note.xml b/journeys/lnurl/lnurl-pay-comment-note.xml index 8129529ac6..8620306c38 100644 --- a/journeys/lnurl/lnurl-pay-comment-note.xml +++ b/journeys/lnurl/lnurl-pay-comment-note.xml @@ -1,11 +1,23 @@ Verifies that the comment typed when paying an LNURL-pay request is saved as the Invoice note of - the sent Lightning activity, and that it survives an app restart. Requires an LNURL-pay endpoint - that returns a description-hash invoice and allows comments (commentAllowed of at least 12), such - as the bitkit-docker lnurl-server, reachable from the same Lightning network as the app. The - staging regtest backend the dev flavor uses has no such endpoint. Also requires a spending balance - of at least 1,000 sats plus routing fees. + the sent Lightning activity, and that it survives an app restart. + + Requires an LNURL-pay endpoint that issues description-hash invoices (LUD-06) and allows comments + (commentAllowed of at least 12), reachable from the same Lightning network as the app: an + lnurl-node `LNURL.createServer` backed by a local LND, as the bitkit-e2e-tests lnurl suite sets up + in test/specs/lnurl.e2e.ts. The endpoint also needs minSendable below maxSendable with 1,000 sats + (1,000,000 msat) in range: a fixed amount goes straight to Confirm and skips the amount screen, so + the amount steps would fail, and the e2e suite's own pay ranges (149,500-200,999 msat) do not + cover 1,000 sats. + + The bitkit-docker lnurl-server is not usable here. It builds the invoice with a plain memo that + already contains the comment, so the note reads "LNURL Payment <id> - thanks" on this branch + and on master alike, and the run says nothing about the fix. A note in that shape means the + endpoint issued a memo invoice rather than a description-hash one. + + The staging regtest backend the dev flavor uses has no LNURL-pay endpoint. Also requires a + spending balance of at least 1,000 sats plus routing fees. @@ -36,13 +48,13 @@ Close the result screen and open the newest activity row (testTag "ActivityShort-0") - Verify the Invoice note (testTag "InvoiceNote") shows "thanks" and not the invoice description hash + Verify the Invoice note (testTag "InvoiceNote") is exactly "thanks", not a memo containing it and not the invoice description hash Run adb shell am force-stop to.bitkit.dev, relaunch the app and open the newest activity row (testTag "ActivityShort-0") - Verify the Invoice note (testTag "InvoiceNote") still shows "thanks" + Verify the Invoice note (testTag "InvoiceNote") is still exactly "thanks" From a7d20e7a9d60c46e1d781be07fc8d3191a410425 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 21 Sep 2026 08:24:12 -0300 Subject: [PATCH 5/5] docs: restore journey suite table order after merge Co-Authored-By: Claude Opus 5 (1M context) --- journeys/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/journeys/README.md b/journeys/README.md index af5d3b5264..08d775292b 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -122,8 +122,8 @@ fixtures, push notifications) live in each suite's README. | [deeplinks](deeplinks) | 2 | `bitkit://screen/…` and sheet routing behind the dev-mode gate; no README | | [hardware-wallet](hardware-wallet) | 17 | Trezor over USB; needs the Trezor emulator | | [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 | | [lnurl](lnurl) | 1 | LNURL-pay comment kept as the activity note; needs an LNURL-pay endpoint that allows comments; 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 | | [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 |