diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt index 3498bc0..d81bc3b 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt @@ -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( diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/LnurlPayer.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/LnurlPayer.kt new file mode 100644 index 0000000..389b66a --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/LnurlPayer.kt @@ -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 { + // ── 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 { + 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" } +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/SendViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/SendViewModel.kt index 0ebc8c8..f011efd 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/SendViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/SendViewModel.kt @@ -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.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 { - 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,