refactor: extract LnurlPayer

This commit is contained in:
2026-06-06 16:14:39 +02:00
parent 08df82a284
commit edbcd95a47
3 changed files with 105 additions and 92 deletions
@@ -19,6 +19,7 @@ import com.bitcointxoko.gudariwallet.ui.clipboard.ClipboardViewModel
import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel
import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel
import com.bitcointxoko.gudariwallet.ui.receive.ReceiveViewModel
import com.bitcointxoko.gudariwallet.ui.send.LnurlPayer
import com.bitcointxoko.gudariwallet.ui.send.LnurlScanner
import com.bitcointxoko.gudariwallet.ui.send.SendViewModel
@@ -31,6 +32,7 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
val repo = LNbitsWalletRepository(api = api, secrets = secrets)
val lnurlCache = LnurlCacheRepository(app)
val lnurlScanner = LnurlScanner(repo = repo, lnurlCache = lnurlCache)
val lnurlPayer = LnurlPayer(repo = repo, scanner = lnurlScanner, cache = lnurlCache)
val paymentCache = PaymentCacheRepository(app)
val fiatDataStore = FiatDataStore(app)
val balancePrefs = BalancePrefsStore(app)
@@ -46,7 +48,8 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
onWithdrawScanned = { raw, lnurl ->
receiveVm.handleWithdrawResponse(raw, lnurl)
},
scanner = lnurlScanner
scanner = lnurlScanner,
payer = lnurlPayer
)
val clipboardVm = ClipboardViewModel(
@@ -0,0 +1,91 @@
package com.bitcointxoko.gudariwallet.ui.send
import android.util.Log
import com.bitcointxoko.gudariwallet.api.LnurlScanResponse
import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository
import com.bitcointxoko.gudariwallet.data.WalletRepository
import com.bitcointxoko.gudariwallet.util.lnurlProtocolError
class LnurlPayer(
private val repo : WalletRepository,
private val scanner : LnurlScanner,
private val cache : LnurlCacheRepository,
) {
/**
* Attempts payment via direct → proxy. On transient failure, invalidates
* the cache, rescans, and retries once. Returns the paymentHash on success.
*/
suspend fun pay(
rawRes : LnurlScanResponse,
lnurl : String,
amountMsat: Long,
comment : String?,
): Result<String> {
// ── 1. Initial attempt
val initial = tryWithFallback(rawRes, lnurl, amountMsat, comment, "INITIAL")
if (initial.isSuccess) return initial
// ── 2. Terminal error — don't retry
val initialError = initial.exceptionOrNull() ?: Exception("Payment failed")
if (isTerminalPayError(initialError)) {
Log.w(TAG, "LNURL [PAY TERMINAL] ${initialError.message}")
return Result.failure(initialError)
}
// ── 3. Invalidate cache and rescan
Log.w(TAG, "LNURL [PAY RETRY] invalidating cache and rescanning")
cache.invalidate(lnurl)
val rescanResult = scanner.rescan(lnurl)
if (rescanResult.isFailure) return Result.failure(
rescanResult.exceptionOrNull() ?: Exception("Rescan failed")
)
val freshRaw = rescanResult.getOrThrow()
val protocolError = lnurlProtocolError(freshRaw)
if (protocolError != null) {
Log.w(TAG, "LNURL [RESCAN PROTO] $protocolError")
return Result.failure(Exception(protocolError))
}
cache.put(lnurl, freshRaw)
// ── 4. Final attempt with fresh metadata
val retry = tryWithFallback(freshRaw, lnurl, amountMsat, comment, "RETRY")
if (retry.isSuccess) return retry
Log.e(TAG, "LNURL [PAY FINAL ERR] ${retry.exceptionOrNull()?.message}")
return Result.failure(retry.exceptionOrNull() ?: Exception("Payment failed"))
}
private suspend fun tryWithFallback(
rawRes : LnurlScanResponse,
lnurl : String,
amountMsat: Long,
comment : String?,
label : String,
): Result<String> {
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")
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"))
}
private fun isTerminalPayError(e: Throwable): Boolean {
val msg = e.message?.lowercase() ?: return false
return msg.contains("insufficient balance") ||
msg.contains("unauthorized") ||
msg.contains("amount") && msg.contains("bound")
}
companion object { private const val TAG = "LnurlPayer" }
}
@@ -55,6 +55,7 @@ class SendViewModel(
private val balanceVm : BalanceViewModel,
private val onWithdrawScanned: (raw: LnurlScanResponse, lnurl: String) -> Unit,
private val scanner : LnurlScanner,
private val payer : LnurlPayer,
) : ViewModel() {
private val _sendState = MutableStateFlow<SendState>(SendState.Idle)
@@ -201,7 +202,6 @@ class SendViewModel(
Log.w(TAG, "HOLD INVOICE [POLL TIMEOUT] $paymentHash — leaving in Paying state")
}
fun payLnurl(
rawRes : LnurlScanResponse,
lnurl : String,
@@ -212,53 +212,22 @@ class SendViewModel(
viewModelScope.launch {
_sendState.value = SendState.Paying
// ── 1. Initial attempt (direct → proxy)
val initial = tryPayLnurlWithFallback(rawRes, lnurl, amountMsat, comment, "INITIAL", strings)
if (initial.isSuccess) {
recordPaymentSuccess(initial.getOrThrow(), amountMsat, strings = strings)
return@launch
}
val result = payer.pay(rawRes, lnurl, amountMsat, comment)
// ── 2. Both paths failed — check if retrying is worthwhile
val initialError = initial.exceptionOrNull() ?: Exception(strings.paymentFailed)
if (isTerminalPayError(initialError)) {
Log.w(TAG, "LNURL [PAY TERMINAL] ${initialError.message} — not retrying")
if (result.isSuccess) {
recordPaymentSuccess(result.getOrThrow(), amountMsat, strings = strings)
} else {
_sendState.value = SendState.Error(
parseApiError(initialError, strings.paymentFailed) // ← "Payment failed"
parseApiError(
result.exceptionOrNull() ?: Exception(strings.paymentFailed),
strings.paymentFailed
)
)
return@launch
}
Log.w(TAG, "LNURL [PAY RETRY ] — invalidating cache and retrying with fresh scan")
lnurlCache.invalidate(lnurl)
// ── 3. Re-fetch metadata
val freshRaw = rescanLnurl(lnurl, strings) ?: return@launch // error already set
val scanProtocolError = lnurlProtocolError(freshRaw)
if (scanProtocolError != null) {
Log.w(TAG, "LNURL [RESCAN PROTO] $scanProtocolError")
_sendState.value = SendState.Error(scanProtocolError)
return@launch
}
lnurlCache.put(lnurl, freshRaw)
// ── 4. Final attempt (direct → proxy) with fresh metadata
val retry = tryPayLnurlWithFallback(freshRaw, lnurl, amountMsat, comment, "RETRY", strings)
if (retry.isSuccess) {
recordPaymentSuccess(retry.getOrThrow(), amountMsat, strings = strings)
return@launch
}
// ── 5. Final failure
val retryError = retry.exceptionOrNull() ?: Exception(strings.paymentFailed)
Log.e(TAG, "LNURL [PAY FINAL ERR] ${retryError.message}")
_sendState.value = SendState.Error(
parseApiError(retryError, strings.paymentFailed) // ← "Payment failed"
)
}
}
fun fetchLnurlInvoice(
rawRes : LnurlScanResponse,
lnurl : String,
@@ -367,7 +336,6 @@ class SendViewModel(
}
}
/**
* Biometric gate. Shows prompt if device supports it; calls [pay] directly if not.
*/
@@ -563,55 +531,6 @@ class SendViewModel(
)
}
private suspend fun tryPayLnurlWithFallback(
rawRes : LnurlScanResponse,
lnurl : String,
amountMsat: Long,
comment : String?,
label : String,
strings : SendStrings,
): Result<String> {
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")
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(strings.paymentFailed) // ← "Payment failed"
)
}
private suspend fun rescanLnurl(lnurl: String, strings: SendStrings): LnurlScanResponse? {
val result = scanner.rescan(lnurl)
if (result.isSuccess) return result.getOrThrow()
Log.e(TAG, "LNURL [RESCAN ERR] ${result.exceptionOrNull()?.message}")
_sendState.value = SendState.Error(
parseApiError(
result.exceptionOrNull() ?: Exception("Rescan failed"),
strings.paymentFailedRescan
)
)
return null
}
private fun isTerminalPayError(e: Throwable?): Boolean {
if (e == null) return false
val msg = e.message?.lowercase() ?: return false
return msg.contains("insufficient balance") ||
msg.contains("unauthorized") ||
msg.contains("amount") && msg.contains("bound")
}
private fun resolveAliasesInBackground(
payee : String?,
routeHints : List<RouteHintHop>,