diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt index b167f66..ba6ed06 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt @@ -8,24 +8,18 @@ import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository import com.bitcointxoko.gudariwallet.data.NodeAliasRepository import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository import com.bitcointxoko.gudariwallet.data.WalletRepository -import com.bitcointxoko.gudariwallet.util.LnurlMetadataParser -import com.bitcointxoko.gudariwallet.util.RouteHintAnalyzer import com.bitcointxoko.gudariwallet.util.SendInputDetector import com.bitcointxoko.gudariwallet.util.SendInputType -import com.bitcointxoko.gudariwallet.util.WalletConstants -import com.bitcointxoko.gudariwallet.util.parseApiError import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import com.bitcointxoko.gudariwallet.util.lnurlProtocolError import androidx.fragment.app.FragmentActivity -import com.bitcointxoko.gudariwallet.api.RouteHintHop import com.bitcointxoko.gudariwallet.data.BalancePrefsStore import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel import com.bitcointxoko.gudariwallet.ui.receive.ReceiveViewModel -import com.bitcointxoko.gudariwallet.util.BiometricHelper +import com.bitcointxoko.gudariwallet.ui.send.SendViewModel private const val TAG = "WalletViewModel" @@ -38,6 +32,7 @@ class WalletViewModel( val balanceVm : BalanceViewModel, val fiatVm : FiatViewModel, val receiveVm : ReceiveViewModel, + val sendVm : SendViewModel, ) : ViewModel() { // ── Balance state ───────────────────────────────────────────────────────── @@ -56,11 +51,19 @@ class WalletViewModel( fun resetReceiveState() = receiveVm.resetReceiveState() fun executeWithdraw(k1: String, callback: String, amountSats: Long, memo: String) = receiveVm.executeWithdraw(k1, callback, amountSats, memo) - + val sendState: StateFlow get() = sendVm.sendState + fun scan(rawInput: String) = sendVm.scan(rawInput) + fun payBolt11(bolt11: String) = sendVm.payBolt11(bolt11) + fun payLnurl(rawRes: LnurlScanResponse, lnurl: String, amountMsat: Long, comment: String?) = + sendVm.payLnurl(rawRes, lnurl, amountMsat, comment) + fun fetchLnurlInvoice(rawRes: LnurlScanResponse, lnurl: String, domain: String, amountMsat: Long, comment: String?) = + sendVm.fetchLnurlInvoice(rawRes, lnurl, domain, amountMsat, comment) + fun payLnurlInvoice(state: SendState.LnurlInvoiceReady) = sendVm.payLnurlInvoice(state) + fun requestPayment(activity: FragmentActivity, subtitle: String, pay: () -> Unit) = + sendVm.requestPayment(activity, subtitle, pay) + fun resetSendState() = sendVm.resetSendState() // ── Other states ────────────────────────────────────────────────────────── - private val _sendState = MutableStateFlow(SendState.Idle) - val sendState: StateFlow = _sendState private val _lightningAddress = MutableStateFlow(balancePrefs.lightningAddress) val lightningAddress: StateFlow = _lightningAddress @@ -101,260 +104,6 @@ class WalletViewModel( } } - // ── Send ────────────────────────────────────────────────────────────────── - - fun scan(rawInput: String) { - val normalized = SendInputDetector.normalize(rawInput) - val type = SendInputDetector.detect(rawInput) - Log.d(TAG, "SCAN [DETECT] input='$rawInput' → type=$type") - - when (type) { - SendInputType.Unknown -> { - val msg = if (rawInput.trim().lowercase().startsWith("keyauth")) { - "Lightning Login (LNURL-auth) is not yet supported" - } else { - "Unrecognised input — paste a BOLT-11 invoice, LNURL, or Lightning Address" - } - _sendState.value = SendState.Error(msg) - } - SendInputType.Bip21 -> handleBip21Scan(rawInput) - SendInputType.Lud17Url -> handleLud17Scan(rawInput) - SendInputType.Bolt11 -> handleBolt11Scan(normalized) - else -> handleLnurlScan(normalized) - } - } - - fun payBolt11(bolt11: String) { - viewModelScope.launch { - val amountSats = (_sendState.value as? SendState.Bolt11Decoded)?.amountSats ?: 0L - _sendState.value = SendState.Paying - runCatching { repo.payBolt11(bolt11) } - .onSuccess { response -> - val feeSats = runCatching { - repo.getPaymentDetail(response.paymentHash) - .details - ?.feeSat - }.getOrNull() - _sendState.value = SendState.PaymentSent( - amountSats = amountSats, - feeSats = feeSats - ) - refreshBalance() - } - .onFailure { e -> - Log.e(TAG, "SEND [PAY BOLT11 ERROR] ${e.message}") - _sendState.value = SendState.Error(parseApiError(e, "Payment failed")) - } - } - } - - fun payLnurl( - rawRes : LnurlScanResponse, - lnurl : String, - amountMsat: Long, - comment : String? - ) { - viewModelScope.launch { - _sendState.value = SendState.Paying - - // ── 1. Initial attempt (direct → proxy) ─────────────────────────────── - val initial = tryPayLnurlWithFallback(rawRes, lnurl, amountMsat, comment, "INITIAL") - if (initial.isSuccess) { - recordPaymentSuccess(initial.getOrThrow(), amountMsat) - return@launch - } - - // ── 2. Both paths failed — check if retrying is worthwhile ──────────── - val initialError = initial.exceptionOrNull() ?: Exception("Payment failed") - if (isTerminalPayError(initialError)) { - Log.w(TAG, "LNURL [PAY TERMINAL] ${initialError.message} — not retrying") - _sendState.value = SendState.Error( - parseApiError(initialError, "Payment failed") - ) - 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) ?: 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") - if (retry.isSuccess) { - recordPaymentSuccess(retry.getOrThrow(), amountMsat) - return@launch - } - - // ── 5. Final failure ────────────────────────────────────────────────── - val retryError = retry.exceptionOrNull() ?: Exception("Payment failed") - Log.e(TAG, "LNURL [PAY FINAL ERR] ${retryError.message}") - _sendState.value = SendState.Error( - parseApiError(retryError, "Payment failed") - ) - } - } - - 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") - } - - // ── Step 1: fetch the LNURL callback invoice, decode it, analyse route hints - // Called after the user taps "Pay" in LnurlPayForm and passes biometric auth. - // Emits LnurlInvoiceReady (with routeRisk populated if fees are elevated). - fun fetchLnurlInvoice( - rawRes : LnurlScanResponse, - lnurl : String, - domain : String, - amountMsat: Long, - comment : String? - ) { - viewModelScope.launch { - _sendState.value = SendState.Scanning - - // ── 1. Hit the callback URL — returns the raw bolt11 string - val bolt11 = runCatching { - repo.fetchLnurlCallbackInvoice(rawRes, amountMsat, comment) - }.getOrElse { e -> - Log.e(TAG, "LNURL [FETCH INVOICE ERR] ${e.message}") - _sendState.value = SendState.Error( - parseApiError(e, "Could not fetch invoice from recipient") - ) - return@launch - } - - // ── 2. Decode the bolt11 to get memo and route hints - val decoded = runCatching { - repo.decodeBolt11(bolt11) - }.getOrElse { e -> - Log.e(TAG, "LNURL [DECODE ERR] ${e.message}") - _sendState.value = SendState.Error(parseApiError(e, "Could not decode invoice")) - return@launch - } - - // ── 3. Run route-hint fee analysis (same logic as the Bolt11 path) - val routeRisk = RouteHintAnalyzer.analyze( - amountMsat = amountMsat, - hints = decoded.routeHints - ) - - Log.d(TAG, "LNURL [INVOICE READY] amountMsat=$amountMsat " + - "routeRisk=${routeRisk?.level} hints=${decoded.routeHints.size}") - - // ── 4. Emit LnurlInvoiceReady — UI shows warning card if risk != null - _sendState.value = SendState.LnurlInvoiceReady( - bolt11 = bolt11, - amountSats = amountMsat / WalletConstants.MSAT_PER_SAT, - memo = decoded.memo, - domain = domain, - lnurl = lnurl, - rawRes = rawRes, - amountMsat = amountMsat, - comment = comment, - payee = decoded.payee, - nodeAlias = null, - routeRisk = routeRisk, - allRouteHints = decoded.routeHints, - routeHintAliases = emptyMap() - ) - - resolveAliasesInBackground( - payee = decoded.payee, - routeHints = decoded.routeHints, - readState = { _sendState.value }, - copyAlias = { state, alias -> - (state as? SendState.LnurlInvoiceReady) - ?.takeIf { it.payee == decoded.payee } - ?.copy(nodeAlias = alias) - }, - copyHint = { state, pubkey, alias -> - (state as? SendState.LnurlInvoiceReady) - ?.copy(routeHintAliases = state.routeHintAliases + (pubkey to alias)) - } - ) - } - } - - // ── Step 2: pay the bolt11 that was fetched in fetchLnurlInvoice - // Called after the user confirms in LnurlInvoiceConfirmCard. - // Tries payBolt11 directly first; falls back to the full payLnurl path - // (which includes rescan/retry logic) if the direct attempt fails. - fun payLnurlInvoice(state: SendState.LnurlInvoiceReady) { - viewModelScope.launch { - _sendState.value = SendState.Paying - - // ── 1. Direct bolt11 payment — fastest path, no server round-trip - val directResult = runCatching { repo.payBolt11(state.bolt11) } - - if (directResult.isSuccess) { - val feeSats = runCatching { - repo.getPaymentDetail(directResult.getOrThrow().paymentHash) - .details?.feeSat - }.getOrNull() - _sendState.value = SendState.PaymentSent( - amountSats = state.amountSats, - feeSats = feeSats - ) - refreshBalance() - return@launch - } - - Log.w(TAG, "LNURL [PAY INVOICE DIRECT FAIL] " + - "${directResult.exceptionOrNull()?.message} — falling back to payLnurl") - - // ── 2. Direct failed — delegate to payLnurl which has full - // rescan/retry logic and handles both direct + proxy paths - payLnurl( - rawRes = state.rawRes, - lnurl = state.lnurl, - amountMsat = state.amountMsat, - comment = state.comment - ) - } - } - - /** - * Gate for payment confirmation. Shows a biometric/device-credential prompt - * if the device supports it; calls [pay] directly if it does not. - * - * On a hard auth failure the send state is set to [SendState.Error] so the - * UI surfaces the message without any extra wiring. - */ - fun requestPayment( - activity: FragmentActivity, - subtitle: String, - pay : () -> Unit - ) { - if (!BiometricHelper.canAuthenticate(activity)) { - // Device has no enrolled biometric or credential — pay directly. - pay() - return - } - BiometricHelper.prompt( - activity = activity, - subtitle = subtitle, - onSuccess = pay, - onError = { _, msg -> - _sendState.value = SendState.Error("Authentication failed: $msg") - } - ) - } - - fun resetSendState() { _sendState.value = SendState.Idle } - // ── Clipboard offer ─────────────────────────────────────────────────────── private var lastOfferedClipboard: String? = null @@ -397,301 +146,4 @@ class WalletViewModel( } fun refreshBalance() = balanceVm.refreshBalance() - - // ── Private helpers ─────────────────────────────────────────────────────── - - private fun buildLnurlReadyState(raw: LnurlScanResponse, lnurl: String): SendState.LnurlReady { - return SendState.LnurlReady( - callback = raw.callback ?: "", - description = LnurlMetadataParser.parseDescription(raw.metadata), - domain = LnurlMetadataParser.extractDomain(raw.callback), - minSats = (raw.minSendable ?: 0L) / WalletConstants.MSAT_PER_SAT, - maxSats = (raw.maxSendable ?: 0L) / WalletConstants.MSAT_PER_SAT, - commentAllowed = raw.commentAllowed ?: 0, - lnurl = lnurl, - rawRes = raw - ) - } - - /** - * 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 { - // 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 - * pubkey in [routeHints]. - * - * [readState] — returns the current SendState to check it's still relevant. - * [copyAlias] — called with the resolved payee alias; return the new state or - * null to skip the update. - * [copyHint] — called with (pubkey, alias); return the new state or null to - * skip the update. - */ - private fun resolveAliasesInBackground( - payee : String?, - routeHints : List, - readState : () -> SendState, - copyAlias : (SendState, String) -> SendState?, - copyHint : (SendState, String, String) -> SendState? - ) { - payee?.let { pubkey -> - viewModelScope.launch { - val alias = aliasRepo.resolve(pubkey) ?: return@launch - val current = readState() - copyAlias(current, alias)?.let { _sendState.value = it } - } - } - routeHints.map { it.publicKey }.distinct().forEach { pubkey -> - viewModelScope.launch { - val alias = aliasRepo.resolve(pubkey) ?: return@launch - val current = readState() - copyHint(current, pubkey, alias)?.let { _sendState.value = it } - } - } - } - /** - * Parses the query string of a BIP-21 URI into a key→value map. - * Handles URL-encoding and `+`-as-space. - */ - private fun parseBip21Params(raw: String): Map { - val query = raw.trim().substringAfter("?", missingDelimiterValue = "") - if (query.isBlank()) return emptyMap() - return query.split("&").mapNotNull { pair -> - val key = pair.substringBefore("=").takeIf { it.isNotBlank() } ?: return@mapNotNull null - val value = runCatching { - java.net.URLDecoder.decode( - pair.substringAfter("=", "").replace("+", " "), "UTF-8" - ) - }.getOrDefault("") - key to value - }.toMap() - } - - private fun handleBip21Scan(rawInput: String) { - val params = parseBip21Params(rawInput) - val bolt11 = params["lightning"] - val lnurl = params["lnurl"] ?: params["lnurlp"] - when { - bolt11 != null -> scan(bolt11) - lnurl != null -> scan(lnurl) - else -> _sendState.value = SendState.Error( - "On-chain Bitcoin payments are not supported — " + - "share a BIP-21 URI with a lightning= parameter" - ) - } - } - private fun handleLud17Scan(rawInput: String) { - val scheme = rawInput.trim().lowercase().substringBefore("://") - if (scheme == "lnurlc") { - _sendState.value = SendState.Error("Channel requests (lnurlc) are not supported") - return - } - val httpsUrl = "https://" + rawInput.trim().substringAfter("://") - Log.d(TAG, "LUD17 [FETCH] $httpsUrl") - _sendState.value = SendState.Scanning - viewModelScope.launch { - runCatching { repo.fetchLnurlDirect(httpsUrl) } - .onSuccess { raw -> - Log.d(TAG, "LUD17 [RESPONSE] tag=${raw.tag}") - when (raw.tag) { - "withdrawRequest" -> { - _sendState.value = SendState.Idle - receiveVm.handleWithdrawResponse(raw, httpsUrl) - } - "payRequest" -> { - lnurlCache.put(httpsUrl, raw) - _sendState.value = buildLnurlReadyState(raw, httpsUrl) - } - else -> _sendState.value = SendState.Error( - "Unsupported LNURL type: ${raw.tag ?: "unknown"}" - ) - } - } - .onFailure { e -> - Log.e(TAG, "LUD17 [ERROR] ${e.message}") - _sendState.value = SendState.Error(parseApiError(e, "Could not resolve address")) - } - } - } - private fun handleBolt11Scan(normalized: String) { - viewModelScope.launch { - _sendState.value = SendState.Scanning - runCatching { repo.decodeBolt11(normalized) } - .onSuccess { decoded -> - val routeRisk = RouteHintAnalyzer.analyze( - amountMsat = decoded.amountSats * 1_000L, - hints = decoded.routeHints - ) - _sendState.value = SendState.Bolt11Decoded( - bolt11 = normalized, - amountSats = decoded.amountSats, - memo = decoded.memo, - payee = decoded.payee, - nodeAlias = null, - routeRisk = routeRisk, - allRouteHints = decoded.routeHints, - routeHintAliases = emptyMap() - ) - resolveAliasesInBackground( - payee = decoded.payee, - routeHints = decoded.routeHints, - readState = { _sendState.value }, - copyAlias = { state, alias -> - (state as? SendState.Bolt11Decoded) - ?.takeIf { it.payee == decoded.payee } - ?.copy(nodeAlias = alias) - }, - copyHint = { state, pubkey, alias -> - (state as? SendState.Bolt11Decoded) - ?.copy(routeHintAliases = state.routeHintAliases + (pubkey to alias)) - } - ) - } - .onFailure { e -> - Log.e(TAG, "SEND [DECODE ERROR] ${e.message}") - _sendState.value = SendState.Error(parseApiError(e, "Could not decode invoice")) - } - } - } - private fun handleLnurlScan(normalized: String) { - viewModelScope.launch { - _sendState.value = SendState.Scanning - - // 1. Cache hit - val cached = lnurlCache.get(normalized) - if (cached != null) { - Log.d(TAG, "LNURL [SCAN CACHE ] $normalized — skipping network call") - when (cached.tag) { - "withdrawRequest" -> { - _sendState.value = SendState.Idle - receiveVm.handleWithdrawResponse(cached, normalized) - } - else -> _sendState.value = buildLnurlReadyState(cached, normalized) - } - return@launch - } - Log.d(TAG, "LNURL [SCAN START ] normalized=$normalized") - - // 2. Client-side direct - val directResult = runCatching { repo.scanLnurlDirect(normalized) } - if (directResult.isSuccess) { - val scanned = directResult.getOrThrow() - Log.d(TAG, "LNURL [SCAN DIRECT] tag=${scanned.tag} for $normalized") - val protocolError = lnurlProtocolError(scanned.rawRes) - if (protocolError != null) { - Log.w(TAG, "LNURL [SCAN PROTO ERR] $protocolError") - _sendState.value = SendState.Error(protocolError) - return@launch - } - lnurlCache.put(normalized, scanned.rawRes) - when (scanned.tag) { - "payRequest" -> _sendState.value = buildLnurlReadyState(scanned.rawRes, normalized) - "withdrawRequest" -> { - _sendState.value = SendState.Idle - receiveVm.handleWithdrawResponse(scanned.rawRes, normalized) - } - null -> _sendState.value = SendState.Error("Empty response from server") - else -> _sendState.value = SendState.Error("Unsupported type: ${scanned.tag}") - } - return@launch - } - Log.w(TAG, "LNURL [SCAN DIRECT FAIL] ${directResult.exceptionOrNull()?.message} — falling back to proxy") - - // 3. Server proxy fallback - runCatching { repo.scanLnurl(normalized) } - .onSuccess { scanned -> - Log.d(TAG, "LNURL [SCAN PROXY ] tag=${scanned.tag} for $normalized") - val protocolError = lnurlProtocolError(scanned.rawRes) - if (protocolError != null) { - Log.w(TAG, "LNURL [SCAN PROTO ERR] $protocolError") - _sendState.value = SendState.Error(protocolError) - return@onSuccess - } - lnurlCache.put(normalized, scanned.rawRes) - when (scanned.tag) { - "payRequest" -> _sendState.value = buildLnurlReadyState(scanned.rawRes, normalized) - "withdrawRequest" -> { - _sendState.value = SendState.Idle - receiveVm.handleWithdrawResponse(scanned.rawRes, normalized) - } - null -> _sendState.value = SendState.Error("Empty response from server") - else -> _sendState.value = SendState.Error("Unsupported type: ${scanned.tag}") - } - } - .onFailure { e -> - _sendState.value = SendState.Error(parseApiError(e, "Could not resolve address")) - } - } - } } \ No newline at end of file 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 15c6ca2..03c7e55 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt @@ -8,12 +8,14 @@ import com.bitcointxoko.gudariwallet.data.BalancePrefsStore import com.bitcointxoko.gudariwallet.data.LNbitsNodeAliasRepository import com.bitcointxoko.gudariwallet.data.LNbitsWalletRepository import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository +import com.bitcointxoko.gudariwallet.data.NodeAliasRepository import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository import com.bitcointxoko.gudariwallet.security.EncryptedSecretStore import com.bitcointxoko.gudariwallet.service.NodeAliasService import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel import com.bitcointxoko.gudariwallet.ui.receive.ReceiveViewModel +import com.bitcointxoko.gudariwallet.ui.send.SendViewModel class WalletViewModelFactory(private val app: Application) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { @@ -21,27 +23,37 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F val baseUrl = secrets.baseUrl.ifBlank { "https://placeholder.invalid" } val api = LNbitsClient.create(baseUrl) val nodeAliasSvc = NodeAliasService(httpClient = LNbitsClient.httpClient, context = app) + val lnurlCache = LnurlCacheRepository(app) + val paymentCache = PaymentCacheRepository(app) + val aliasRepo = LNbitsNodeAliasRepository(nodeAliasSvc) val repo = LNbitsWalletRepository(api = api, secrets = secrets, context = app) val balancePrefs = BalancePrefsStore(app) val balanceVm = BalanceViewModel(repo, balancePrefs) val fiatVm = FiatViewModel(repo, balancePrefs, balanceVm) val receiveVm = ReceiveViewModel(repo) - + val sendVm = SendViewModel( + repo = repo, + aliasRepo = aliasRepo, + lnurlCache = lnurlCache, + balanceVm = balanceVm, + onWithdrawScanned = { raw, lnurl -> + receiveVm.handleWithdrawResponse(raw, lnurl) + } + ) balanceVm.onBalanceRefreshed = { fiatVm.onBalanceRefreshed() } - val lnurlCache = LnurlCacheRepository(app) - val paymentCache = PaymentCacheRepository(app) @Suppress("UNCHECKED_CAST") return WalletViewModel( repo, - aliasRepo, - lnurlCache, - paymentCache, - balancePrefs, - balanceVm, - fiatVm, - receiveVm + aliasRepo = aliasRepo, + lnurlCache = lnurlCache, + paymentCache = paymentCache, + balancePrefs = balancePrefs, + balanceVm = balanceVm, + fiatVm = fiatVm, + receiveVm = receiveVm, + sendVm = sendVm ) as T } } \ No newline at end of file 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 new file mode 100644 index 0000000..3982ac4 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/SendViewModel.kt @@ -0,0 +1,544 @@ +package com.bitcointxoko.gudariwallet.ui.send + +import android.util.Log +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.bitcointxoko.gudariwallet.api.LnurlScanResponse +import com.bitcointxoko.gudariwallet.api.RouteHintHop +import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository +import com.bitcointxoko.gudariwallet.data.NodeAliasRepository +import com.bitcointxoko.gudariwallet.data.WalletRepository +import com.bitcointxoko.gudariwallet.ui.SendState +import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel +import com.bitcointxoko.gudariwallet.util.BiometricHelper +import com.bitcointxoko.gudariwallet.util.LnurlMetadataParser +import com.bitcointxoko.gudariwallet.util.RouteHintAnalyzer +import com.bitcointxoko.gudariwallet.util.SendInputDetector +import com.bitcointxoko.gudariwallet.util.SendInputType +import com.bitcointxoko.gudariwallet.util.WalletConstants +import com.bitcointxoko.gudariwallet.util.lnurlProtocolError +import com.bitcointxoko.gudariwallet.util.parseApiError +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +private const val TAG = "SendViewModel" + +class SendViewModel( + private val repo : WalletRepository, + private val aliasRepo : NodeAliasRepository, + private val lnurlCache : LnurlCacheRepository, + private val balanceVm : BalanceViewModel, + // Called when a withdrawRequest is scanned — hands off to ReceiveViewModel + // without creating a hard dependency between the two. + private val onWithdrawScanned: (raw: LnurlScanResponse, lnurl: String) -> Unit, +) : ViewModel() { + + private val _sendState = MutableStateFlow(SendState.Idle) + val sendState: StateFlow = _sendState + + // ── Entry point ────────────────────────────────────────────────────────── + + fun scan(rawInput: String) { + val normalized = SendInputDetector.normalize(rawInput) + val type = SendInputDetector.detect(rawInput) + Log.d(TAG, "SCAN [DETECT] input='$rawInput' → type=$type") + + when (type) { + SendInputType.Unknown -> { + val msg = if (rawInput.trim().lowercase().startsWith("keyauth")) { + "Lightning Login (LNURL-auth) is not yet supported" + } else { + "Unrecognised input — paste a BOLT-11 invoice, LNURL, or Lightning Address" + } + _sendState.value = SendState.Error(msg) + } + SendInputType.Bip21 -> handleBip21Scan(rawInput) + SendInputType.Lud17Url -> handleLud17Scan(rawInput) + SendInputType.Bolt11 -> handleBolt11Scan(normalized) + else -> handleLnurlScan(normalized) + } + } + + // ── Payment ────────────────────────────────────────────────────────────── + + fun payBolt11(bolt11: String) { + viewModelScope.launch { + val amountSats = (_sendState.value as? SendState.Bolt11Decoded)?.amountSats ?: 0L + _sendState.value = SendState.Paying + runCatching { repo.payBolt11(bolt11) } + .onSuccess { response -> + val feeSats = runCatching { + repo.getPaymentDetail(response.paymentHash).details?.feeSat + }.getOrNull() + _sendState.value = SendState.PaymentSent( + amountSats = amountSats, + feeSats = feeSats + ) + balanceVm.refreshBalance() + } + .onFailure { e -> + Log.e(TAG, "SEND [PAY BOLT11 ERROR] ${e.message}") + _sendState.value = SendState.Error(parseApiError(e, "Payment failed")) + } + } + } + + fun payLnurl( + rawRes : LnurlScanResponse, + lnurl : String, + amountMsat: Long, + comment : String? + ) { + viewModelScope.launch { + _sendState.value = SendState.Paying + + // ── 1. Initial attempt (direct → proxy) + val initial = tryPayLnurlWithFallback(rawRes, lnurl, amountMsat, comment, "INITIAL") + if (initial.isSuccess) { + recordPaymentSuccess(initial.getOrThrow(), amountMsat) + return@launch + } + + // ── 2. Both paths failed — check if retrying is worthwhile + val initialError = initial.exceptionOrNull() ?: Exception("Payment failed") + if (isTerminalPayError(initialError)) { + Log.w(TAG, "LNURL [PAY TERMINAL] ${initialError.message} — not retrying") + _sendState.value = SendState.Error(parseApiError(initialError, "Payment failed")) + 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) ?: 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") + if (retry.isSuccess) { + recordPaymentSuccess(retry.getOrThrow(), amountMsat) + return@launch + } + + // ── 5. Final failure + val retryError = retry.exceptionOrNull() ?: Exception("Payment failed") + Log.e(TAG, "LNURL [PAY FINAL ERR] ${retryError.message}") + _sendState.value = SendState.Error(parseApiError(retryError, "Payment failed")) + } + } + + /** + * Step 1: fetch the LNURL callback invoice, decode it, analyse route hints. + * Called after the user taps "Pay" in LnurlPayForm and passes biometric auth. + */ + fun fetchLnurlInvoice( + rawRes : LnurlScanResponse, + lnurl : String, + domain : String, + amountMsat: Long, + comment : String? + ) { + viewModelScope.launch { + _sendState.value = SendState.Scanning + + val bolt11 = runCatching { + repo.fetchLnurlCallbackInvoice(rawRes, amountMsat, comment) + }.getOrElse { e -> + Log.e(TAG, "LNURL [FETCH INVOICE ERR] ${e.message}") + _sendState.value = SendState.Error( + parseApiError(e, "Could not fetch invoice from recipient") + ) + return@launch + } + + val decoded = runCatching { + repo.decodeBolt11(bolt11) + }.getOrElse { e -> + Log.e(TAG, "LNURL [DECODE ERR] ${e.message}") + _sendState.value = SendState.Error(parseApiError(e, "Could not decode invoice")) + return@launch + } + + val routeRisk = RouteHintAnalyzer.analyze( + amountMsat = amountMsat, + hints = decoded.routeHints + ) + + Log.d(TAG, "LNURL [INVOICE READY] amountMsat=$amountMsat " + + "routeRisk=${routeRisk?.level} hints=${decoded.routeHints.size}") + + _sendState.value = SendState.LnurlInvoiceReady( + bolt11 = bolt11, + amountSats = amountMsat / WalletConstants.MSAT_PER_SAT, + memo = decoded.memo, + domain = domain, + lnurl = lnurl, + rawRes = rawRes, + amountMsat = amountMsat, + comment = comment, + payee = decoded.payee, + nodeAlias = null, + routeRisk = routeRisk, + allRouteHints = decoded.routeHints, + routeHintAliases = emptyMap() + ) + + resolveAliasesInBackground( + payee = decoded.payee, + routeHints = decoded.routeHints, + readState = { _sendState.value }, + copyAlias = { state, alias -> + (state as? SendState.LnurlInvoiceReady) + ?.takeIf { it.payee == decoded.payee } + ?.copy(nodeAlias = alias) + }, + copyHint = { state, pubkey, alias -> + (state as? SendState.LnurlInvoiceReady) + ?.copy(routeHintAliases = state.routeHintAliases + (pubkey to alias)) + } + ) + } + } + + /** + * Step 2: pay the bolt11 fetched in fetchLnurlInvoice. + * Tries payBolt11 directly first; falls back to the full payLnurl path. + */ + fun payLnurlInvoice(state: SendState.LnurlInvoiceReady) { + viewModelScope.launch { + _sendState.value = SendState.Paying + + val directResult = runCatching { repo.payBolt11(state.bolt11) } + + if (directResult.isSuccess) { + val feeSats = runCatching { + repo.getPaymentDetail(directResult.getOrThrow().paymentHash).details?.feeSat + }.getOrNull() + _sendState.value = SendState.PaymentSent( + amountSats = state.amountSats, + feeSats = feeSats + ) + balanceVm.refreshBalance() + return@launch + } + + Log.w(TAG, "LNURL [PAY INVOICE DIRECT FAIL] " + + "${directResult.exceptionOrNull()?.message} — falling back to payLnurl") + + payLnurl( + rawRes = state.rawRes, + lnurl = state.lnurl, + amountMsat = state.amountMsat, + comment = state.comment + ) + } + } + + /** + * Biometric gate. Shows prompt if device supports it; calls [pay] directly if not. + */ + fun requestPayment( + activity: FragmentActivity, + subtitle: String, + pay : () -> Unit + ) { + if (!BiometricHelper.canAuthenticate(activity)) { + pay() + return + } + BiometricHelper.prompt( + activity = activity, + subtitle = subtitle, + onSuccess = pay, + onError = { _, msg -> + _sendState.value = SendState.Error("Authentication failed: $msg") + } + ) + } + + fun resetSendState() { _sendState.value = SendState.Idle } + + // ── Scan handlers ──────────────────────────────────────────────────────── + + private fun handleBip21Scan(rawInput: String) { + val params = parseBip21Params(rawInput) + val bolt11 = params["lightning"] + val lnurl = params["lnurl"] ?: params["lnurlp"] + when { + bolt11 != null -> scan(bolt11) + lnurl != null -> scan(lnurl) + else -> _sendState.value = SendState.Error( + "On-chain Bitcoin payments are not supported — " + + "share a BIP-21 URI with a lightning= parameter" + ) + } + } + + private fun handleLud17Scan(rawInput: String) { + val scheme = rawInput.trim().lowercase().substringBefore("://") + if (scheme == "lnurlc") { + _sendState.value = SendState.Error("Channel requests (lnurlc) are not supported") + return + } + val httpsUrl = "https://" + rawInput.trim().substringAfter("://") + Log.d(TAG, "LUD17 [FETCH] $httpsUrl") + _sendState.value = SendState.Scanning + viewModelScope.launch { + runCatching { repo.fetchLnurlDirect(httpsUrl) } + .onSuccess { raw -> + Log.d(TAG, "LUD17 [RESPONSE] tag=${raw.tag}") + when (raw.tag) { + "withdrawRequest" -> { + _sendState.value = SendState.Idle + onWithdrawScanned(raw, httpsUrl) + } + "payRequest" -> { + lnurlCache.put(httpsUrl, raw) + _sendState.value = buildLnurlReadyState(raw, httpsUrl) + } + else -> _sendState.value = SendState.Error( + "Unsupported LNURL type: ${raw.tag ?: "unknown"}" + ) + } + } + .onFailure { e -> + Log.e(TAG, "LUD17 [ERROR] ${e.message}") + _sendState.value = SendState.Error(parseApiError(e, "Could not resolve address")) + } + } + } + + private fun handleBolt11Scan(normalized: String) { + viewModelScope.launch { + _sendState.value = SendState.Scanning + runCatching { repo.decodeBolt11(normalized) } + .onSuccess { decoded -> + val routeRisk = RouteHintAnalyzer.analyze( + amountMsat = decoded.amountSats * 1_000L, + hints = decoded.routeHints + ) + _sendState.value = SendState.Bolt11Decoded( + bolt11 = normalized, + amountSats = decoded.amountSats, + memo = decoded.memo, + payee = decoded.payee, + nodeAlias = null, + routeRisk = routeRisk, + allRouteHints = decoded.routeHints, + routeHintAliases = emptyMap() + ) + resolveAliasesInBackground( + payee = decoded.payee, + routeHints = decoded.routeHints, + readState = { _sendState.value }, + copyAlias = { state, alias -> + (state as? SendState.Bolt11Decoded) + ?.takeIf { it.payee == decoded.payee } + ?.copy(nodeAlias = alias) + }, + copyHint = { state, pubkey, alias -> + (state as? SendState.Bolt11Decoded) + ?.copy(routeHintAliases = state.routeHintAliases + (pubkey to alias)) + } + ) + } + .onFailure { e -> + Log.e(TAG, "SEND [DECODE ERROR] ${e.message}") + _sendState.value = SendState.Error(parseApiError(e, "Could not decode invoice")) + } + } + } + + private fun handleLnurlScan(normalized: String) { + viewModelScope.launch { + _sendState.value = SendState.Scanning + + // 1. Cache hit + val cached = lnurlCache.get(normalized) + if (cached != null) { + Log.d(TAG, "LNURL [SCAN CACHE ] $normalized — skipping network call") + when (cached.tag) { + "withdrawRequest" -> { + _sendState.value = SendState.Idle + onWithdrawScanned(cached, normalized) + } + else -> _sendState.value = buildLnurlReadyState(cached, normalized) + } + return@launch + } + Log.d(TAG, "LNURL [SCAN START ] normalized=$normalized") + + // 2. Client-side direct + val directResult = runCatching { repo.scanLnurlDirect(normalized) } + if (directResult.isSuccess) { + val scanned = directResult.getOrThrow() + Log.d(TAG, "LNURL [SCAN DIRECT] tag=${scanned.tag} for $normalized") + val protocolError = lnurlProtocolError(scanned.rawRes) + if (protocolError != null) { + Log.w(TAG, "LNURL [SCAN PROTO ERR] $protocolError") + _sendState.value = SendState.Error(protocolError) + return@launch + } + lnurlCache.put(normalized, scanned.rawRes) + when (scanned.tag) { + "payRequest" -> _sendState.value = buildLnurlReadyState(scanned.rawRes, normalized) + "withdrawRequest" -> { + _sendState.value = SendState.Idle + onWithdrawScanned(scanned.rawRes, normalized) + } + null -> _sendState.value = SendState.Error("Empty response from server") + else -> _sendState.value = SendState.Error("Unsupported type: ${scanned.tag}") + } + return@launch + } + Log.w(TAG, "LNURL [SCAN DIRECT FAIL] ${directResult.exceptionOrNull()?.message} — falling back to proxy") + + // 3. Server proxy fallback + runCatching { repo.scanLnurl(normalized) } + .onSuccess { scanned -> + Log.d(TAG, "LNURL [SCAN PROXY ] tag=${scanned.tag} for $normalized") + val protocolError = lnurlProtocolError(scanned.rawRes) + if (protocolError != null) { + Log.w(TAG, "LNURL [SCAN PROTO ERR] $protocolError") + _sendState.value = SendState.Error(protocolError) + return@onSuccess + } + lnurlCache.put(normalized, scanned.rawRes) + when (scanned.tag) { + "payRequest" -> _sendState.value = buildLnurlReadyState(scanned.rawRes, normalized) + "withdrawRequest" -> { + _sendState.value = SendState.Idle + onWithdrawScanned(scanned.rawRes, normalized) + } + null -> _sendState.value = SendState.Error("Empty response from server") + else -> _sendState.value = SendState.Error("Unsupported type: ${scanned.tag}") + } + } + .onFailure { e -> + _sendState.value = SendState.Error(parseApiError(e, "Could not resolve address")) + } + } + } + + // ── Private helpers ────────────────────────────────────────────────────── + + private fun buildLnurlReadyState(raw: LnurlScanResponse, lnurl: String): SendState.LnurlReady { + return SendState.LnurlReady( + callback = raw.callback ?: "", + description = LnurlMetadataParser.parseDescription(raw.metadata), + domain = LnurlMetadataParser.extractDomain(raw.callback), + minSats = (raw.minSendable ?: 0L) / WalletConstants.MSAT_PER_SAT, + maxSats = (raw.maxSendable ?: 0L) / WalletConstants.MSAT_PER_SAT, + commentAllowed = raw.commentAllowed ?: 0, + lnurl = lnurl, + rawRes = raw + ) + } + + 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 + ) + balanceVm.refreshBalance() + } + + private suspend fun tryPayLnurlWithFallback( + 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 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 + } + + 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, + readState : () -> SendState, + copyAlias : (SendState, String) -> SendState?, + copyHint : (SendState, String, String) -> SendState? + ) { + payee?.let { pubkey -> + viewModelScope.launch { + val alias = aliasRepo.resolve(pubkey) ?: return@launch + val current = readState() + copyAlias(current, alias)?.let { _sendState.value = it } + } + } + routeHints.map { it.publicKey }.distinct().forEach { pubkey -> + viewModelScope.launch { + val alias = aliasRepo.resolve(pubkey) ?: return@launch + val current = readState() + copyHint(current, pubkey, alias)?.let { _sendState.value = it } + } + } + } + + private fun parseBip21Params(raw: String): Map { + val query = raw.trim().substringAfter("?", missingDelimiterValue = "") + if (query.isBlank()) return emptyMap() + return query.split("&").mapNotNull { pair -> + val key = pair.substringBefore("=").takeIf { it.isNotBlank() } ?: return@mapNotNull null + val value = runCatching { + java.net.URLDecoder.decode( + pair.substringAfter("=", "").replace("+", " "), "UTF-8" + ) + }.getOrDefault("") + key to value + }.toMap() + } +} \ No newline at end of file