From ee6be8f425b4503e3e1c15875bc04097f183e2ef Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 16 Sep 2026 17:27:21 -0300 Subject: [PATCH 1/2] fix: refresh lightning activity on retry Co-Authored-By: Claude Opus 5 (1M context) --- .../java/to/bitkit/services/CoreService.kt | 29 +++- .../to/bitkit/services/CoreServiceTest.kt | 159 ++++++++++++++++++ changelog.d/next/1042.fixed.md | 1 + 3 files changed, 186 insertions(+), 3 deletions(-) create mode 100644 changelog.d/next/1042.fixed.md diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 8dc09d80c0..df9c042f42 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -324,6 +324,28 @@ private fun OnchainActivity.withRecoveredTransfer(recoveredChannelId: String?): else -> copy(isTransfer = true, channelId = channelId ?: recoveredChannelId) } +/** + * Applies the latest LDK payment details to a stored Lightning activity. + * + * A retry of the same invoice reuses the payment hash, so the stored row must take the final + * attempt's amount, fee and preimage. Missing values keep the stored ones, and an empty + * description never wipes a stored message. + */ +internal fun LightningActivity.withPaymentUpdate( + payment: PaymentDetails, + kind: PaymentKind.Bolt11, + state: PaymentState, + contact: String?, +): LightningActivity = copy( + value = payment.amountSats ?: value, + fee = payment.feePaidMsat?.let { msatFloorOf(it) } ?: fee, + preimage = kind.preimage ?: preimage, + message = kind.description?.takeIf { it.isNotEmpty() } ?: message, + updatedAt = payment.latestUpdateTimestamp, + status = state, + contact = contact, +) + @Suppress("LargeClass", "TooManyFunctions") class ActivityService( @Suppress("unused") private val coreService: CoreService, // used to ensure CoreService inits first @@ -746,9 +768,10 @@ class ActivityService( ?: privatePaykitContactPublicKeyForReceivedInvoicePaymentHash(payment.id, payment.direction) val ln = if (existingActivity is Activity.Lightning) { - existingActivity.v1.copy( - updatedAt = payment.latestUpdateTimestamp, - status = state, + existingActivity.v1.withPaymentUpdate( + payment = payment, + kind = kind, + state = state, contact = contact, ) } else { diff --git a/app/src/test/java/to/bitkit/services/CoreServiceTest.kt b/app/src/test/java/to/bitkit/services/CoreServiceTest.kt index cb1da2b8cb..506602e024 100644 --- a/app/src/test/java/to/bitkit/services/CoreServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/CoreServiceTest.kt @@ -6,6 +6,10 @@ import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentState import com.synonym.bitkitcore.PaymentType 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 to.bitkit.ext.create import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -194,6 +198,116 @@ class CoreServiceTest { assertEquals(listOf(lightning), result.toUpsert) } + @Test + fun `payment update refreshes amount fee preimage and status on successful retry`() { + val failed = failedSend(value = 300_000uL, fee = 5uL, preimage = null) + + val result = failed.withPaymentUpdate( + payment = payment(amountMsat = 22_000uL, feePaidMsat = 1_999uL, latestUpdateTimestamp = 200uL), + kind = bolt11(preimage = "preimage"), + state = PaymentState.SUCCEEDED, + contact = "contact", + ) + + assertEquals(22uL, result.value) + assertEquals(1uL, result.fee) + assertEquals("preimage", result.preimage) + assertEquals(PaymentState.SUCCEEDED, result.status) + assertEquals(200uL, result.updatedAt) + assertEquals("contact", result.contact) + } + + @Test + fun `payment update keeps untouched fields of the existing row`() { + val failed = failedSend(value = 300_000uL, fee = 5uL, preimage = null) + + val result = failed.withPaymentUpdate( + payment = payment(amountMsat = 22_000uL, feePaidMsat = 1_000uL, latestUpdateTimestamp = 200uL), + kind = bolt11(preimage = "preimage", bolt11 = "other-invoice"), + state = PaymentState.SUCCEEDED, + contact = null, + ) + + assertEquals(failed.id, result.id) + assertEquals(failed.txType, result.txType) + assertEquals(failed.timestamp, result.timestamp) + assertEquals(failed.invoice, result.invoice) + assertEquals(failed.seenAt, result.seenAt) + assertEquals(failed.createdAt, result.createdAt) + } + + @Test + fun `payment update keeps prior fee when fee paid is unknown`() { + val result = failedSend(fee = 5uL).withPaymentUpdate( + payment = payment(feePaidMsat = null), + kind = bolt11(), + state = PaymentState.SUCCEEDED, + contact = null, + ) + + assertEquals(5uL, result.fee) + } + + @Test + fun `payment update keeps prior value when amount is unknown`() { + val result = failedSend(value = 300_000uL).withPaymentUpdate( + payment = payment(amountMsat = null), + kind = bolt11(), + state = PaymentState.SUCCEEDED, + contact = null, + ) + + assertEquals(300_000uL, result.value) + } + + @Test + fun `payment update keeps prior preimage when retry has none`() { + val result = failedSend(preimage = "old-preimage").withPaymentUpdate( + payment = payment(), + kind = bolt11(preimage = null), + state = PaymentState.PENDING, + contact = null, + ) + + assertEquals("old-preimage", result.preimage) + } + + @Test + fun `payment update keeps prior message when description is null`() { + val result = failedSend(message = "coffee").withPaymentUpdate( + payment = payment(), + kind = bolt11(description = null), + state = PaymentState.SUCCEEDED, + contact = null, + ) + + assertEquals("coffee", result.message) + } + + @Test + fun `payment update keeps prior message when description is empty`() { + val result = failedSend(message = "coffee").withPaymentUpdate( + payment = payment(), + kind = bolt11(description = ""), + state = PaymentState.SUCCEEDED, + contact = null, + ) + + assertEquals("coffee", result.message) + } + + @Test + fun `payment update replaces message with non-empty description`() { + val result = failedSend(message = "coffee").withPaymentUpdate( + payment = payment(), + kind = bolt11(description = "tea"), + state = PaymentState.SUCCEEDED, + contact = null, + ) + + assertEquals("tea", result.message) + } + private fun mergePlan( existing: List, incoming: List, @@ -246,4 +360,49 @@ class CoreServiceTest { timestamp = 1uL, ) ) + + private fun failedSend( + value: ULong = 300_000uL, + fee: ULong = 0uL, + message: String = "", + preimage: String? = null, + ) = LightningActivity.create( + walletId = "wallet", + id = "payment-hash", + txType = PaymentType.SENT, + status = PaymentState.FAILED, + value = value, + invoice = "invoice", + timestamp = 100uL, + fee = fee, + message = message, + preimage = preimage, + seenAt = 150uL, + ) + + private fun payment( + amountMsat: ULong? = 22_000uL, + feePaidMsat: ULong? = 1_000uL, + latestUpdateTimestamp: ULong = 200uL, + ) = PaymentDetails( + id = "payment-hash", + kind = bolt11(), + amountMsat = amountMsat, + feePaidMsat = feePaidMsat, + direction = PaymentDirection.OUTBOUND, + status = PaymentStatus.SUCCEEDED, + latestUpdateTimestamp = latestUpdateTimestamp, + ) + + private fun bolt11( + preimage: String? = null, + description: String? = null, + bolt11: String? = "invoice", + ) = PaymentKind.Bolt11( + hash = "payment-hash", + preimage = preimage, + secret = null, + description = description, + bolt11 = bolt11, + ) } diff --git a/changelog.d/next/1042.fixed.md b/changelog.d/next/1042.fixed.md new file mode 100644 index 0000000000..d68d7b86f3 --- /dev/null +++ b/changelog.d/next/1042.fixed.md @@ -0,0 +1 @@ +Retried Lightning payments now show the amount and fee of the attempt that succeeded. From da30e55803d90c2384aea111b65d2f3a414f18af Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 16 Sep 2026 17:32:22 -0300 Subject: [PATCH 2/2] fix: keep stored message on lightning activity update Co-Authored-By: Claude Opus 5 (1M context) --- .../java/to/bitkit/services/CoreService.kt | 5 ++-- .../to/bitkit/services/CoreServiceTest.kt | 24 +++++-------------- 2 files changed, 8 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index df9c042f42..cc81b25586 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -328,8 +328,8 @@ private fun OnchainActivity.withRecoveredTransfer(recoveredChannelId: String?): * Applies the latest LDK payment details to a stored Lightning activity. * * A retry of the same invoice reuses the payment hash, so the stored row must take the final - * attempt's amount, fee and preimage. Missing values keep the stored ones, and an empty - * description never wipes a stored message. + * attempt's amount, fee and preimage. Missing values keep the stored ones. The stored message is + * never replaced, because LDK reports a description-hash invoice's hash as its description. */ internal fun LightningActivity.withPaymentUpdate( payment: PaymentDetails, @@ -340,7 +340,6 @@ internal fun LightningActivity.withPaymentUpdate( value = payment.amountSats ?: value, fee = payment.feePaidMsat?.let { msatFloorOf(it) } ?: fee, preimage = kind.preimage ?: preimage, - message = kind.description?.takeIf { it.isNotEmpty() } ?: message, updatedAt = payment.latestUpdateTimestamp, status = state, contact = contact, diff --git a/app/src/test/java/to/bitkit/services/CoreServiceTest.kt b/app/src/test/java/to/bitkit/services/CoreServiceTest.kt index 506602e024..a5b59b755e 100644 --- a/app/src/test/java/to/bitkit/services/CoreServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/CoreServiceTest.kt @@ -273,31 +273,19 @@ class CoreServiceTest { } @Test - fun `payment update keeps prior message when description is null`() { - val result = failedSend(message = "coffee").withPaymentUpdate( + fun `payment update keeps prior message when description is a description hash`() { + val result = failedSend(message = "lnurl comment").withPaymentUpdate( payment = payment(), - kind = bolt11(description = null), + kind = bolt11(description = "a".repeat(64)), state = PaymentState.SUCCEEDED, contact = null, ) - assertEquals("coffee", result.message) + assertEquals("lnurl comment", result.message) } @Test - fun `payment update keeps prior message when description is empty`() { - val result = failedSend(message = "coffee").withPaymentUpdate( - payment = payment(), - kind = bolt11(description = ""), - state = PaymentState.SUCCEEDED, - contact = null, - ) - - assertEquals("coffee", result.message) - } - - @Test - fun `payment update replaces message with non-empty description`() { + fun `payment update keeps prior message when description differs`() { val result = failedSend(message = "coffee").withPaymentUpdate( payment = payment(), kind = bolt11(description = "tea"), @@ -305,7 +293,7 @@ class CoreServiceTest { contact = null, ) - assertEquals("tea", result.message) + assertEquals("coffee", result.message) } private fun mergePlan(