refactor: WalletViewModel payLnurl

This commit is contained in:
2026-06-01 19:48:22 +02:00
parent 40f7aa00ae
commit 5179b24b44
@@ -586,7 +586,6 @@ class WalletViewModel(
} }
} }
fun payLnurl( fun payLnurl(
rawRes : LnurlScanResponse, rawRes : LnurlScanResponse,
lnurl : String, lnurl : String,
@@ -596,124 +595,53 @@ class WalletViewModel(
viewModelScope.launch { viewModelScope.launch {
_sendState.value = SendState.Paying _sendState.value = SendState.Paying
// ── 1. Try client-side pay first ────────────────────────────────────── // ── 1. Initial attempt (direct → proxy) ───────────────────────────────
val directResult = runCatching { val initial = tryPayLnurlWithFallback(rawRes, lnurl, amountMsat, comment, "INITIAL")
repo.payLnurlDirect(rawRes, amountMsat, comment) if (initial.isSuccess) {
} recordPaymentSuccess(initial.getOrThrow(), amountMsat)
if (directResult.isSuccess) {
val feeSats = runCatching {
repo.getPaymentDetail(directResult.getOrThrow().paymentHash)
.details?.feeSat
}.getOrNull()
_sendState.value = SendState.PaymentSent(
amountSats = amountMsat / WalletConstants.MSAT_PER_SAT,
feeSats = feeSats
)
refreshBalance()
return@launch return@launch
} }
Log.w(TAG, "LNURL [PAY DIRECT FAIL] ${directResult.exceptionOrNull()?.message} — falling back to server proxy") // ── 2. Both paths failed — check if retrying is worthwhile ────────────
val initialError = initial.exceptionOrNull() ?: Exception("Payment failed")
// ── 2. Client-side failed — try server proxy ─────────────────────────── if (isTerminalPayError(initialError)) {
val firstResult = runCatching { repo.payLnurl(rawRes, lnurl, amountMsat, comment) } Log.w(TAG, "LNURL [PAY TERMINAL] ${initialError.message} — not retrying")
if (firstResult.isSuccess) {
val feeSats = runCatching {
repo.getPaymentDetail(firstResult.getOrThrow().paymentHash)
.details?.feeSat
}.getOrNull()
_sendState.value = SendState.PaymentSent(
amountSats = amountMsat / WalletConstants.MSAT_PER_SAT,
feeSats = feeSats
)
refreshBalance()
return@launch
}
// ── 3. Both failed — check if worth retrying ───────────────────────────
val firstError = firstResult.exceptionOrNull()
if (isTerminalPayError(firstError)) {
Log.w(TAG, "LNURL [PAY TERMINAL] ${firstError?.message} — not retrying")
_sendState.value = SendState.Error( _sendState.value = SendState.Error(
parseApiError(firstError ?: Exception("Payment failed"), "Payment failed") parseApiError(initialError, "Payment failed")
) )
return@launch return@launch
} }
Log.w(TAG, "LNURL [PAY RETRY ] ${firstError?.message} — invalidating cache and retrying with fresh scan") Log.w(TAG, "LNURL [PAY RETRY ] — invalidating cache and retrying with fresh scan")
lnurlCache.invalidate(lnurl) lnurlCache.invalidate(lnurl)
// ── 4. Re-fetch metadata — try client-side first, then proxy ────────── // ── 3. Re-fetch metadata ──────────────────────────────────────────────
val freshRaw: LnurlScanResponse = run rescan@{ val freshRaw = rescanLnurl(lnurl) ?: return@launch // error already set
// Try client-side rescan
val directRescan = runCatching { repo.scanLnurlDirect(lnurl) }
if (directRescan.isSuccess) {
Log.d(TAG, "LNURL [RESCAN DIRECT] success")
return@rescan directRescan.getOrThrow().rawRes
}
Log.w(TAG, "LNURL [RESCAN DIRECT FAIL] ${directRescan.exceptionOrNull()?.message} — trying proxy rescan")
val proxyRescan = runCatching { repo.scanLnurl(lnurl) }
if (proxyRescan.isSuccess) {
return@rescan proxyRescan.getOrThrow().rawRes
}
Log.e(TAG, "LNURL [RESCAN ERR] ${proxyRescan.exceptionOrNull()?.message}")
_sendState.value = SendState.Error(
parseApiError(
proxyRescan.exceptionOrNull() ?: Exception(),
"Payment failed — could not re-fetch address"
)
)
return@launch
}
val scanProtocolError = lnurlProtocolError(freshRaw) val scanProtocolError = lnurlProtocolError(freshRaw)
if (scanProtocolError != null) { if (scanProtocolError != null) {
Log.w(TAG, "LNURL [RESCAN PROTO] $scanProtocolError") Log.w(TAG, "LNURL [RESCAN PROTO] $scanProtocolError")
_sendState.value = SendState.Error(scanProtocolError) _sendState.value = SendState.Error(scanProtocolError)
return@launch return@launch
} }
lnurlCache.put(lnurl, freshRaw) lnurlCache.put(lnurl, freshRaw)
// ── 5. Final attempt — client-side first, then proxy ────────────────── // ── 4. Final attempt (direct → proxy) with fresh metadata ─────────────
val finalDirectResult = runCatching { val retry = tryPayLnurlWithFallback(freshRaw, lnurl, amountMsat, comment, "RETRY")
repo.payLnurlDirect(freshRaw, amountMsat, comment) if (retry.isSuccess) {
} recordPaymentSuccess(retry.getOrThrow(), amountMsat)
if (finalDirectResult.isSuccess) {
val feeSats = runCatching {
repo.getPaymentDetail(finalDirectResult.getOrThrow().paymentHash)
.details?.feeSat
}.getOrNull()
_sendState.value = SendState.PaymentSent(
amountSats = amountMsat / WalletConstants.MSAT_PER_SAT,
feeSats = feeSats
)
refreshBalance()
return@launch return@launch
} }
runCatching { repo.payLnurl(freshRaw, lnurl, amountMsat, comment) } // ── 5. Final failure ──────────────────────────────────────────────────
.onSuccess { result -> val retryError = retry.exceptionOrNull() ?: Exception("Payment failed")
val feeSats = runCatching { Log.e(TAG, "LNURL [PAY FINAL ERR] ${retryError.message}")
repo.getPaymentDetail(result.paymentHash).details?.feeSat _sendState.value = SendState.Error(
}.getOrNull() parseApiError(retryError, "Payment failed")
_sendState.value = SendState.PaymentSent( )
amountSats = amountMsat / WalletConstants.MSAT_PER_SAT,
feeSats = feeSats
)
refreshBalance()
}
.onFailure { e ->
Log.e(TAG, "LNURL [PAY FINAL ERR] ${e.message}")
_sendState.value = SendState.Error(parseApiError(e, "Payment failed"))
}
} }
} }
private fun isTerminalPayError(e: Throwable?): Boolean { private fun isTerminalPayError(e: Throwable?): Boolean {
if (e == null) return false if (e == null) return false
val msg = e.message?.lowercase() ?: return false val msg = e.message?.lowercase() ?: return false
@@ -902,13 +830,11 @@ class WalletViewModel(
_clipboardOfferState.value = ClipboardOfferState.Detected(raw = trimmed, inputType = type) _clipboardOfferState.value = ClipboardOfferState.Detected(raw = trimmed, inputType = type)
} }
fun dismissClipboardOffer() { fun dismissClipboardOffer() {
_clipboardOfferState.value = ClipboardOfferState.None _clipboardOfferState.value = ClipboardOfferState.None
} }
// ── Private helpers ─────────────────────────────────────────────────────── // ── Private helpers ───────────────────────────────────────────────────────
private fun buildLnurlReadyState(raw: LnurlScanResponse, lnurl: String): SendState.LnurlReady { private fun buildLnurlReadyState(raw: LnurlScanResponse, lnurl: String): SendState.LnurlReady {
return SendState.LnurlReady( return SendState.LnurlReady(
callback = raw.callback ?: "", callback = raw.callback ?: "",
@@ -922,6 +848,81 @@ class WalletViewModel(
) )
} }
/**
* Records a successful payment: fetches the fee, updates send state,
* and refreshes the balance. Centralises the 4 identical success blocks.
*/
private suspend fun recordPaymentSuccess(paymentHash: String, amountMsat: Long) {
val feeSats = runCatching {
repo.getPaymentDetail(paymentHash).details?.feeSat
}.getOrNull()
_sendState.value = SendState.PaymentSent(
amountSats = amountMsat / WalletConstants.MSAT_PER_SAT,
feeSats = feeSats
)
refreshBalance()
}
/**
* Attempts a client-side LNURL payment, then falls back to the server proxy.
* Returns [Result.success] with the payment hash, or [Result.failure] with the
* last exception (from the proxy attempt) so the caller can inspect it without
* a second network round-trip.
*/
private suspend fun tryPayLnurlWithFallback(
rawRes : LnurlScanResponse,
lnurl : String,
amountMsat: Long,
comment : String?,
label : String
): Result<String> {
// 1. Client-side direct
val direct = runCatching { repo.payLnurlDirect(rawRes, amountMsat, comment) }
if (direct.isSuccess) {
Log.d(TAG, "LNURL [PAY $label DIRECT] success")
return Result.success(direct.getOrThrow().paymentHash)
}
Log.w(TAG, "LNURL [PAY $label DIRECT FAIL] ${direct.exceptionOrNull()?.message} — trying proxy")
// 2. Server proxy — its exception becomes the Result.failure
val proxy = runCatching { repo.payLnurl(rawRes, lnurl, amountMsat, comment) }
if (proxy.isSuccess) {
Log.d(TAG, "LNURL [PAY $label PROXY] success")
return Result.success(proxy.getOrThrow().paymentHash)
}
Log.w(TAG, "LNURL [PAY $label PROXY FAIL] ${proxy.exceptionOrNull()?.message}")
return Result.failure(proxy.exceptionOrNull() ?: Exception("Payment failed"))
}
/**
* Re-fetches LNURL metadata: tries client-side first, then server proxy.
* Returns the fresh [LnurlScanResponse], or null if both fail (in which case
* [_sendState] is already set to [SendState.Error]).
*/
private suspend fun rescanLnurl(lnurl: String): LnurlScanResponse? {
val direct = runCatching { repo.scanLnurlDirect(lnurl) }
if (direct.isSuccess) {
Log.d(TAG, "LNURL [RESCAN DIRECT] success")
return direct.getOrThrow().rawRes
}
Log.w(TAG, "LNURL [RESCAN DIRECT FAIL] ${direct.exceptionOrNull()?.message} — trying proxy rescan")
val proxy = runCatching { repo.scanLnurl(lnurl) }
if (proxy.isSuccess) {
return proxy.getOrThrow().rawRes
}
Log.e(TAG, "LNURL [RESCAN ERR] ${proxy.exceptionOrNull()?.message}")
_sendState.value = SendState.Error(
parseApiError(
proxy.exceptionOrNull() ?: Exception(),
"Payment failed — could not re-fetch address"
)
)
return null
}
/** /**
* Fires background coroutines to resolve node aliases for [payee] and each * Fires background coroutines to resolve node aliases for [payee] and each
* pubkey in [routeHints]. * pubkey in [routeHints].