refactor: extract LnurlPayer
This commit is contained in:
@@ -19,6 +19,7 @@ import com.bitcointxoko.gudariwallet.ui.clipboard.ClipboardViewModel
|
|||||||
import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel
|
import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel
|
||||||
import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel
|
import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel
|
||||||
import com.bitcointxoko.gudariwallet.ui.receive.ReceiveViewModel
|
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.LnurlScanner
|
||||||
import com.bitcointxoko.gudariwallet.ui.send.SendViewModel
|
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 repo = LNbitsWalletRepository(api = api, secrets = secrets)
|
||||||
val lnurlCache = LnurlCacheRepository(app)
|
val lnurlCache = LnurlCacheRepository(app)
|
||||||
val lnurlScanner = LnurlScanner(repo = repo, lnurlCache = lnurlCache)
|
val lnurlScanner = LnurlScanner(repo = repo, lnurlCache = lnurlCache)
|
||||||
|
val lnurlPayer = LnurlPayer(repo = repo, scanner = lnurlScanner, cache = lnurlCache)
|
||||||
val paymentCache = PaymentCacheRepository(app)
|
val paymentCache = PaymentCacheRepository(app)
|
||||||
val fiatDataStore = FiatDataStore(app)
|
val fiatDataStore = FiatDataStore(app)
|
||||||
val balancePrefs = BalancePrefsStore(app)
|
val balancePrefs = BalancePrefsStore(app)
|
||||||
@@ -46,7 +48,8 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
|
|||||||
onWithdrawScanned = { raw, lnurl ->
|
onWithdrawScanned = { raw, lnurl ->
|
||||||
receiveVm.handleWithdrawResponse(raw, lnurl)
|
receiveVm.handleWithdrawResponse(raw, lnurl)
|
||||||
},
|
},
|
||||||
scanner = lnurlScanner
|
scanner = lnurlScanner,
|
||||||
|
payer = lnurlPayer
|
||||||
)
|
)
|
||||||
|
|
||||||
val clipboardVm = ClipboardViewModel(
|
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 balanceVm : BalanceViewModel,
|
||||||
private val onWithdrawScanned: (raw: LnurlScanResponse, lnurl: String) -> Unit,
|
private val onWithdrawScanned: (raw: LnurlScanResponse, lnurl: String) -> Unit,
|
||||||
private val scanner : LnurlScanner,
|
private val scanner : LnurlScanner,
|
||||||
|
private val payer : LnurlPayer,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _sendState = MutableStateFlow<SendState>(SendState.Idle)
|
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")
|
Log.w(TAG, "HOLD INVOICE [POLL TIMEOUT] $paymentHash — leaving in Paying state")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fun payLnurl(
|
fun payLnurl(
|
||||||
rawRes : LnurlScanResponse,
|
rawRes : LnurlScanResponse,
|
||||||
lnurl : String,
|
lnurl : String,
|
||||||
@@ -212,52 +212,21 @@ class SendViewModel(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_sendState.value = SendState.Paying
|
_sendState.value = SendState.Paying
|
||||||
|
|
||||||
// ── 1. Initial attempt (direct → proxy)
|
val result = payer.pay(rawRes, lnurl, amountMsat, comment)
|
||||||
val initial = tryPayLnurlWithFallback(rawRes, lnurl, amountMsat, comment, "INITIAL", strings)
|
|
||||||
if (initial.isSuccess) {
|
|
||||||
recordPaymentSuccess(initial.getOrThrow(), amountMsat, strings = strings)
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 2. Both paths failed — check if retrying is worthwhile
|
if (result.isSuccess) {
|
||||||
val initialError = initial.exceptionOrNull() ?: Exception(strings.paymentFailed)
|
recordPaymentSuccess(result.getOrThrow(), amountMsat, strings = strings)
|
||||||
if (isTerminalPayError(initialError)) {
|
} else {
|
||||||
Log.w(TAG, "LNURL [PAY TERMINAL] ${initialError.message} — not retrying")
|
|
||||||
_sendState.value = SendState.Error(
|
_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(
|
fun fetchLnurlInvoice(
|
||||||
rawRes : LnurlScanResponse,
|
rawRes : LnurlScanResponse,
|
||||||
@@ -367,7 +336,6 @@ class SendViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Biometric gate. Shows prompt if device supports it; calls [pay] directly if not.
|
* 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(
|
private fun resolveAliasesInBackground(
|
||||||
payee : String?,
|
payee : String?,
|
||||||
routeHints : List<RouteHintHop>,
|
routeHints : List<RouteHintHop>,
|
||||||
|
|||||||
Reference in New Issue
Block a user