feat(send): improve Lightning send failure recovery - #1140
Conversation
6a53759 to
873d26e
Compare
873d26e to
776366e
Compare
Greptile SummaryThe PR replaces toast-only Lightning send failures with a recoverable failure screen, localized failure reasons, support-report prefilling, and routing-cache resets for routing-related retries.
Confidence Score: 4/5The PR should not merge until pending QuickPay failures can retry without crashing from cleared payment state. The new Pending branch clears the only QuickPay request state, while the corresponding failure route explicitly returns to a destination that requires that state to be non-null. Files Needing Attention: app/src/main/java/to/bitkit/ui/screens/wallets/send/SendQuickPayScreen.kt, app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt
|
| Filename | Overview |
|---|---|
| app/src/main/java/to/bitkit/ui/screens/wallets/send/SendQuickPayScreen.kt | Moves QuickPay cleanup to terminal results, but clearing state on Pending breaks the newly added pending-failure retry route. |
| app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt | Adds typed failure navigation, support reporting, and routing-aware retry behavior; its QuickPay retry destination still requires state that the Pending path clears. |
| app/src/main/java/to/bitkit/repositories/LightningRepo.kt | Adds graph/scorer cache reset and bounded routing-data refresh orchestration without an independently established defect. |
| app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt | Routes active Lightning failures into the send sheet and preserves typed failure details and payment requests. |
| app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt | Serializes routing-reset retries with a mutex and exposes retry progress to the UI. |
| app/src/main/java/to/bitkit/ext/PaymentFailureReasonExt.kt | Maps LDK failure reasons to localized user copy and sanitized compact support metadata. |
Sequence Diagram
sequenceDiagram
participant User
participant QuickPay as QuickPay Screen
participant AppVM as AppViewModel
participant Pending as Pending Screen
participant Error as Failure Screen
QuickPay->>AppVM: resetQuickPay()
QuickPay->>Pending: "navigate with retryRoute=QuickPay"
Pending-->>Error: payment failure
User->>Error: Try Again
Error->>QuickPay: clear stack and navigate
QuickPay->>AppVM: read quickPayData
AppVM-->>QuickPay: null
QuickPay--xQuickPay: requireNotNull crashes
Reviews (1): Last reviewed commit: "feat(send): improve Lightning send failu..." | Re-trigger Greptile
ovitrif
left a comment
There was a problem hiding this comment.
Three defects remain because the earlier review findings are unresolved:
- Dismissing the send sheet during Try Again fails to restart Lightning because stop is NonCancellable and the reset job is cancelled.
- Writing QuickPay invoice state into sendUiState routes a failed QuickPay retry to Confirm.
- Report-issue prefill overwrites edits because it is reapplied on composition restart.
|
Addressed all comments, waiting for CI to pass. |
| private fun String.compactFailureType(): String { | ||
| val unwrappedOptional = removeSurrounding("Optional(", ")") | ||
| val unwrappedNodeError = unwrappedOptional.removeSurrounding("NodeError(", ")") | ||
| return unwrappedNodeError | ||
| .substringBefore("(") | ||
| .substringAfterLast(".") | ||
| .trim() | ||
| .ifBlank { UNKNOWN_FAILURE_TYPE } | ||
| } |
There was a problem hiding this comment.
Kotlin errors here comes in a different shape, like "LDK Node error: Duplicate payment."
using .substringAfterLast(".") makes the last part always
UNKNOWN_FAILURE_TYPE.
| } | ||
|
|
||
| private val INTERNAL_PAYMENT_ERROR_MARKERS = listOf( | ||
| "Optional(", |
There was a problem hiding this comment.
This is also iOS exclusive error shape
| } | ||
|
|
||
| fun Throwable.toCompactFailureType(): String { | ||
| val rawValue = message?.trim()?.takeIf { it.isNotEmpty() } |
There was a problem hiding this comment.
Android can derive the type from exception class NodeException.DuplicatePayment -> DuplicatePayment, could use it instead of depending on the message
jvsena42
left a comment
There was a problem hiding this comment.
Test-coverage note on compactFailureType(): the assertions only exercise iOS-shaped strings, so the shape Android actually produces is untested — and it currently resolves to Unknown. Details inline.
| val unwrappedNodeError = unwrappedOptional.removeSurrounding("NodeError(", ")") | ||
| return unwrappedNodeError | ||
| .substringBefore("(") | ||
| .substringAfterLast(".") |
There was a problem hiding this comment.
substringAfterLast(".") assumes the iOS input shape. This function is a port of compactFailureType in bitkit-ios (Bitkit/Views/Wallets/Send/SendFailure.swift:41), where the input is String(describing: someType) and the dot split strips a Swift module/type qualifier (LDKNode.NodeError -> NodeError), the same way the Optional( and ( steps handle Swift's optional and associated-value rendering.
On Android the input is Throwable.message, which is a human-readable sentence: Errors.kt:120 builds "LDK Node error: $it" over strings like "Duplicate payment.". So the dot split lands on sentence punctuation rather than a qualifier:
"LDK Node error: Duplicate payment."->substringAfterLast(".")==""->ifBlank { UNKNOWN_FAILURE_TYPE }->"Unknown""LDK Node error: Invalid custom TLVs"(no trailing period) -> no.at all -> the whole"LDK Node error: ..."prefix leaks through as the failure type
Net effect: Failure type: Unknown in the prefilled support ticket for essentially every synchronous LN send failure (insufficient funds, duplicate payment, invalid invoice...). The this::class.simpleName fallback at :61 is already unqualified, so the dot-stripping is vestigial on that path too.
Minimum regression test that pins the trigger without prescribing an implementation:
assertNotEquals("Unknown", Exception("Payment sending failed.").toCompactFailureType())See the test-file comment for the fuller block. Getting a real type name out of an LdkError means reading the inner NodeException class rather than parsing its message — note LdkError.inner is currently private (Errors.kt:31), so that would need widening.
| fun `compact failure types omit optional and node error wrappers`() { | ||
| assertEquals("routeNotFound", PaymentFailureReason.ROUTE_NOT_FOUND.toCompactFailureType()) | ||
| assertEquals("DuplicatePayment", Exception("Optional(NodeError(DuplicatePayment))").toCompactFailureType()) |
There was a problem hiding this comment.
Both inputs here are iOS-shaped, so this passes for the wrong reason. "Optional(NodeError(DuplicatePayment))" is Swift String(describing:) output and happens to carry no trailing period — exactly the case substringAfterLast(".") handles correctly. The Android shape ("LDK Node error: <sentence>." from Errors.kt:120) is never asserted, and it returns "Unknown" today.
Suggested additions:
@Test
fun `compact failure types survive android ldk error messages`() {
// Errors.kt formats NodeException as "LDK Node error: <sentence>."
val withPeriod = LdkError(NodeException.DuplicatePayment("Duplicate payment.")).toCompactFailureType()
val withoutPeriod = LdkError(NodeException.InvalidCustomTlvs("Invalid custom TLVs")).toCompactFailureType()
assertNotEquals("Unknown", withPeriod)
assertFalse(withoutPeriod.contains("LDK Node error"))
}
@Test
fun `compact failure types fall back to the exception class name`() {
assertEquals("IllegalStateException", IllegalStateException().toCompactFailureType())
}
@Test
fun `compact failure types ignore sentence punctuation`() {
assertNotEquals("Unknown", Exception("Payment sending failed.").toCompactFailureType())
}The first and third fail on the current implementation; the second pins the this::class.simpleName path at PaymentFailureReasonExt.kt:61, which is what the ticket falls back to for app-level errors (note it only kicks in when message is null — PaymentRoutingRefreshTimeoutError etc. carry a message and so go down the parsing path).
They are deliberately written as assertNotEquals/assertFalse rather than exact strings so they do not lock in a specific naming scheme. If the fix ends up unwrapping LdkError.inner, these can tighten to assertEquals("DuplicatePayment", ...) / assertEquals("InvalidCustomTlvs", ...).
Needs kotlin.test.assertNotEquals plus to.bitkit.utils.LdkError and org.lightningdevkit.ldknode.NodeException imports. UNKNOWN_FAILURE_TYPE is file-private in the main source, hence the "Unknown" literal.
Description
Ports the iOS Lightning send-failure retry/support behavior to Android.
Optional(...),NodeError, orDuplicatePayment.routeNotFoundandretriesExhausted.SendPendingScreenand fail later.WalletViewModelso overlapping retry flows cannot surface stale timeout errors.Closes #829
Preview
Normal payment:
Screen.Recording.2026-08-11.at.17.59.27.mov
QuickPay:
Screen.Recording.2026-08-12.at.14.55.13.mov
QA Notes
Tested on mainnet emulator:
ROUTE_NOT_FOUNDsurfaces user-facing payment failure copy.RETRIES_EXHAUSTEDfailures surface the localized retries-exhausted copy instead of generic failure copy.Automated checks:
compileDevDebugKotlinPaymentFailureReasonExtTest.ktAppViewModelSendFlowTest.ktdetekt