Skip to content
Merged
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
28 changes: 25 additions & 3 deletions app/src/main/java/to/bitkit/services/CoreService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,27 @@ 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. The stored message is
* never replaced, because LDK reports a description-hash invoice's hash as its description.
*/
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,
updatedAt = payment.latestUpdateTimestamp,
status = state,
contact = contact,
)

@Suppress("LargeClass", "TooManyFunctions")
class ActivityService(
@Suppress("unused") private val coreService: CoreService, // used to ensure CoreService inits first
Expand Down Expand Up @@ -746,9 +767,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 {
Expand Down
147 changes: 147 additions & 0 deletions app/src/test/java/to/bitkit/services/CoreServiceTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -194,6 +198,104 @@ 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 a description hash`() {
val result = failedSend(message = "lnurl comment").withPaymentUpdate(
payment = payment(),
kind = bolt11(description = "a".repeat(64)),
state = PaymentState.SUCCEEDED,
contact = null,
)

assertEquals("lnurl comment", result.message)
}

@Test
fun `payment update keeps prior message when description differs`() {
val result = failedSend(message = "coffee").withPaymentUpdate(
payment = payment(),
kind = bolt11(description = "tea"),
state = PaymentState.SUCCEEDED,
contact = null,
)

assertEquals("coffee", result.message)
}

private fun mergePlan(
existing: List<Activity.Onchain>,
incoming: List<Activity>,
Expand Down Expand Up @@ -246,4 +348,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,
)
}
1 change: 1 addition & 0 deletions changelog.d/next/1042.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Retried Lightning payments now show the amount and fee of the attempt that succeeded.
Loading