package com.bitcointxoko.gudariwallet.ui import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.bitcointxoko.gudariwallet.api.LnurlScanResponse 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.service.WalletNotificationService 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.satsToFiat import com.bitcointxoko.gudariwallet.util.parseApiError import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.SharingStarted 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.data.FiatRateCache import com.bitcointxoko.gudariwallet.util.BiometricHelper private const val TAG = "WalletViewModel" class WalletViewModel( val repo : WalletRepository, val aliasRepo : NodeAliasRepository, val lnurlCache: LnurlCacheRepository, val paymentCache: PaymentCacheRepository, private val balancePrefs: BalancePrefsStore, ) : ViewModel() { // ── Balance state ───────────────────────────────────────────────────────── private val _balanceState = MutableStateFlow(BalanceState.Loading) val balanceState: StateFlow = _balanceState // ── Balance visible state ───────────────────────────────────────────────── private val _balanceHidden = MutableStateFlow(balancePrefs.isBalanceHidden) val balanceHidden: StateFlow = _balanceHidden fun toggleBalanceVisibility() { val next = !_balanceHidden.value _balanceHidden.value = next balancePrefs.setBalanceHidden(next) } // ── Fiat cache ──────────────────────────────────────────────────────────── private val fiatCache = FiatRateCache(balancePrefs) val fiatRate: Double? get() = fiatCache.currentRate val fiatCurrency: String? get() = _selectedCurrency.value val fiatSatsPerUnit: StateFlow = balanceState .mapNotNull { state -> (state as? BalanceState.Success)?.let { s -> if (s.fiatAmount != null && s.sats > 0) s.sats.toDouble() / s.fiatAmount else null } } .stateIn( scope = viewModelScope, started = SharingStarted.Eagerly, initialValue = null ) private val _selectedCurrency = MutableStateFlow(balancePrefs.selectedCurrency) val selectedCurrency: StateFlow = _selectedCurrency fun setSelectedCurrency(currency: String?) { if (currency == _selectedCurrency.value) return _selectedCurrency.value = currency balancePrefs.setSelectedCurrency(currency) fiatCache.invalidateRate() // ← replaces the three raw-var resets Log.d(TAG, "FIAT [CURRENCY ] changed to ${currency ?: "none"} — cache invalidated") if (currency != null) { viewModelScope.launch { fetchAndApplyFiatRate() } } else { val current = _balanceState.value as? BalanceState.Success ?: return _balanceState.value = current.copy(fiatAmount = null, fiatCurrency = null) } } companion object { private const val RATE_TTL_MS = 5 * 60 * 1_000L // 5 minutes } // ── Other states ────────────────────────────────────────────────────────── private val _receiveState = MutableStateFlow(ReceiveState.Idle) val receiveState: StateFlow = _receiveState private val _sendState = MutableStateFlow(SendState.Idle) val sendState: StateFlow = _sendState private val _navigateToReceive = MutableSharedFlow(extraBufferCapacity = 1) val navigateToReceive: SharedFlow = _navigateToReceive private val _lightningAddress = MutableStateFlow(balancePrefs.lightningAddress) val lightningAddress: StateFlow = _lightningAddress // ── Init ────────────────────────────────────────────────────────────────── init { loadBalanceWithCache() observePaymentEvents() fetchLightningAddress() } // ── Balance loading ─────────────────────────────────────────────────────── private fun loadBalanceWithCache() { val savedSats = balancePrefs.savedBalanceSats val savedAt = balancePrefs.balanceSavedAt val ageMs = System.currentTimeMillis() - savedAt if (savedSats >= 0 && ageMs < WalletConstants.BALANCE_CACHE_TTL_MS) { Log.d(TAG, "BALANCE [CACHE HIT ] $savedSats sats (age ${ageMs / 1000}s) — showing immediately") _balanceState.value = BalanceState.Success( sats = savedSats, isRefreshing = true, lastUpdated = savedAt ) } else { if (savedSats >= 0) { Log.d(TAG, "BALANCE [CACHE STALE] $savedSats sats, age ${ageMs / 1000}s > TTL ${WalletConstants.BALANCE_CACHE_TTL_MS / 1000}s — cold load") } else { Log.d(TAG, "BALANCE [CACHE MISS ] No persisted balance — cold load") } _balanceState.value = BalanceState.Loading } refreshBalance() } fun refreshBalance() { val current = _balanceState.value if (current is BalanceState.Success && !current.isRefreshing) { Log.d(TAG, "BALANCE [REFRESH ] Manual refresh triggered (current: ${current.sats} sats)") _balanceState.value = current.copy(isRefreshing = true) } viewModelScope.launch { runCatching { repo.getBalance() } .onSuccess { balance -> val sats = balance.sats val prev = (_balanceState.value as? BalanceState.Success)?.sats when { prev == null -> Log.d(TAG, "BALANCE [NETWORK ] $sats sats — cold load complete") prev == sats -> Log.d(TAG, "BALANCE [NETWORK ] $sats sats — matches cache, no change") else -> Log.d(TAG, "BALANCE [NETWORK ] $sats sats — was $prev sats (diff ${sats - prev})") } _balanceState.value = BalanceState.Success( sats = sats, isRefreshing = false, lastUpdated = System.currentTimeMillis() ) persistBalance(sats) fetchAndApplyFiatRate() } .onFailure { e -> Log.w(TAG, "BALANCE [NET ERROR ] ${e.message}") val staleSats = (_balanceState.value as? BalanceState.Success)?.sats ?: balancePrefs.savedBalanceSats.takeIf { it >= 0 } if (staleSats != null) { Log.w(TAG, "BALANCE [NET ERROR ] Falling back to stale value: $staleSats sats") } else { Log.w(TAG, "BALANCE [NET ERROR ] No stale value available — showing error state") } _balanceState.value = BalanceState.Error( message = e.message ?: "Failed to load balance", staleSats = staleSats ) } } } private fun persistBalance(sats: Long) { balancePrefs.persistBalance(sats) Log.d(TAG, "BALANCE [PERSIST ] $sats sats written to SharedPreferences") } // ── Fiat rate fetch + apply ─────────────────────────────────────────────── private suspend fun fetchAndApplyFiatRate() { val currency = _selectedCurrency.value ?: return val rate: Double = fiatCache.getRate(currency, RATE_TTL_MS) ?: run { // Cache miss — fetch from network runCatching { repo.getFiatRate(currency) } .getOrElse { e -> Log.w(TAG, "FIAT [RATE ERROR ] $currency — ${e.message}") Log.w(TAG, "FIAT [RATE MISS ] $currency — no fallback available, suppressing fiat display") return // nothing to show; leave fiatAmount = null } .also { fresh -> fiatCache.putRate(currency, fresh) } } val current = _balanceState.value as? BalanceState.Success ?: return val fiat = satsToFiat(current.sats, rate) Log.d(TAG, "FIAT [APPLY ] ${current.sats} sats ÷ $rate sats/$currency = $fiat $currency") _balanceState.value = current.copy(fiatAmount = fiat, fiatCurrency = currency) } // ── Currency list ──────────────────────────────────────────────────────── suspend fun getSupportedCurrencies(): List { fiatCache.getCurrencies()?.let { return it } // Cache miss (memory + disk) — fetch from network return fetchAndCacheCurrencies() } private suspend fun fetchAndCacheCurrencies(): List { return runCatching { repo.getSupportedCurrencies() } .getOrElse { e -> Log.w(TAG, "FIAT [CURRENCIES] network fetch failed — ${e.message}") emptyList() } .also { list -> fiatCache.putCurrencies(list) } } // ── WebSocket event collection ──────────────────────────────────────────── private fun observePaymentEvents() { viewModelScope.launch { WalletNotificationService.paymentEvents.collect { event -> Log.d(TAG, "BALANCE [WS EVENT ] ${if (event.isOutgoing) "SENT" else "RECEIVED"} ${event.amountSats} sats") val current = _balanceState.value if (current is BalanceState.Success) { val delta = if (event.isOutgoing) -event.amountSats else event.amountSats val newSats = (current.sats + delta).coerceAtLeast(0L) Log.d(TAG, "BALANCE [WS DELTA ] ${current.sats} ${if (delta >= 0) "+" else ""}$delta = $newSats sats (optimistic)") _balanceState.value = current.copy(sats = newSats, isRefreshing = true) persistBalance(newSats) } else { Log.w(TAG, "BALANCE [WS EVENT ] Received event but balance state is ${current::class.simpleName} — skipping delta") } refreshBalance() fetchAndApplyFiatRate() if (!event.isOutgoing) { val rs = _receiveState.value if (rs is ReceiveState.InvoiceReady) { Log.d(TAG, "BALANCE [WS RECEIVE] Marking invoice ${rs.paymentHash.take(8)}… as received") _receiveState.value = ReceiveState.PaymentReceived( amountSats = event.amountSats, memo = event.memo, checkingId = rs.paymentHash ) } } } } } private fun fetchLightningAddress() { // If we already have a persisted address, show it immediately and // still refresh in the background (address could have changed). viewModelScope.launch { runCatching { repo.getLightningAddress() } .onSuccess { address -> if (address != null) { Log.d(TAG, "LNADDR [FETCH OK ] $address") _lightningAddress.value = address balancePrefs.setLightningAddress(address) } else { Log.d(TAG, "LNADDR [FETCH OK ] no pay link / username found") } } .onFailure { e -> Log.w(TAG, "LNADDR [FETCH ERR] ${e.message} — keeping cached value") // Keep whatever was loaded from prefs; don't clear it on network error } } } // ── Receive ─────────────────────────────────────────────────────────────── fun createInvoice(amountSats: Long, memo: String) { viewModelScope.launch { _receiveState.value = ReceiveState.AwaitingInvoice runCatching { repo.createInvoice(amountSats, memo) } .onSuccess { invoice -> val fallbackExpiry = System.currentTimeMillis() / 1000L + 600L val expiresAt = invoice.expiry?.let { isoString -> runCatching { java.time.LocalDateTime .parse(isoString, java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME) .toEpochSecond(java.time.ZoneOffset.UTC) }.getOrNull() } ?: fallbackExpiry _receiveState.value = ReceiveState.InvoiceReady( bolt11 = invoice.bolt11, paymentHash = invoice.paymentHash, amountSats = amountSats, memo = memo, expiresAt = expiresAt ) } .onFailure { e -> Log.e(TAG, "RECEIVE [CREATE INVOICE ERROR] ${e.message}") _receiveState.value = ReceiveState.Error(parseApiError(e, "Failed to create invoice")) } } } fun resetReceiveState() { _receiveState.value = ReceiveState.Idle } // ── 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) } } private fun handleWithdrawResponse(raw: LnurlScanResponse, lnurl: String) { val minSats = (raw.minWithdrawable ?: 0L) / WalletConstants.MSAT_PER_SAT val maxSats = (raw.maxWithdrawable ?: 0L) / WalletConstants.MSAT_PER_SAT _receiveState.value = ReceiveState.LnurlWithdrawReady( k1 = raw.k1 ?: "", callback = raw.callback ?: "", defaultDescription = raw.defaultDescription ?: "", minSats = minSats, maxSats = maxSats, domain = LnurlMetadataParser.extractDomain(raw.callback), lnurl = lnurl ) _sendState.value = SendState.Idle // clear scanning spinner _navigateToReceive.tryEmit(Unit) Log.d(TAG, "LNURL-WITHDRAW [NAV EVENT] min=$minSats max=$maxSats sats — navigating to Receive") } fun executeWithdraw( k1 : String, callback : String, amountSats: Long, memo : String ) { viewModelScope.launch { _receiveState.value = ReceiveState.AwaitingInvoice val invoiceResult = runCatching { repo.createInvoice(amountSats, memo) } if (invoiceResult.isFailure) { val e = invoiceResult.exceptionOrNull() ?: Exception("Failed to create invoice") Log.e(TAG, "LNURL-WITHDRAW [INVOICE ERROR] ${e.message}") _receiveState.value = ReceiveState.Error(parseApiError(e, "Failed to create invoice")) return@launch } val invoice = invoiceResult.getOrThrow() Log.d(TAG, "LNURL-WITHDRAW [INVOICE] ${invoice.paymentHash} — submitting to callback") runCatching { repo.executeWithdraw(callback, k1, invoice.bolt11) } .onSuccess { response -> if (response.status == "OK") { Log.d(TAG, "LNURL-WITHDRAW [CALLBACK OK] waiting for push payment") _receiveState.value = ReceiveState.InvoiceReady( bolt11 = invoice.bolt11, paymentHash = invoice.paymentHash, amountSats = amountSats, memo = memo, expiresAt = System.currentTimeMillis() / 1000L + 600L ) } else { val reason = response.reason ?.ifBlank { null } ?: "Withdraw service rejected the invoice" Log.w(TAG, "LNURL-WITHDRAW [CALLBACK ERR] $reason") _receiveState.value = ReceiveState.Error(reason) } } .onFailure { e -> Log.w(TAG, "LNURL-WITHDRAW [CALLBACK FAIL] ${e.message}") _receiveState.value = ReceiveState.Error(parseApiError(e, "Withdraw failed")) } } } 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 private val _clipboardOfferState = MutableStateFlow(ClipboardOfferState.None) val clipboardOfferState: StateFlow = _clipboardOfferState fun checkClipboard(text: String?) { Log.d("Clipboard", "checkClipboard called with: $text") val trimmed = text?.trim().takeIf { !it.isNullOrBlank() } ?: return // ── Deduplicate: don't re-offer the same string twice ───────────────── if (trimmed == lastOfferedClipboard) return // ── Guard: ignore our own invoice ───────────────────────────────────── val ownInvoice = (_receiveState.value as? ReceiveState.InvoiceReady)?.bolt11 if (ownInvoice != null && trimmed.equals(ownInvoice, ignoreCase = true)) { Log.d("Clipboard", "Skipping own invoice") lastOfferedClipboard = trimmed // still mark as seen so focus re-checks don't re-trigger return } // ── Guard: ignore our own lightning address ──────────────────────────── val ownAddress = _lightningAddress.value if (ownAddress != null && trimmed.equals(ownAddress, ignoreCase = true)) { Log.d("Clipboard", "Skipping own lightning address") lastOfferedClipboard = trimmed return } val type = SendInputDetector.detect(trimmed) if (type == SendInputType.Unknown) return lastOfferedClipboard = trimmed _clipboardOfferState.value = ClipboardOfferState.Detected(raw = trimmed, inputType = type) } fun dismissClipboardOffer() { _clipboardOfferState.value = ClipboardOfferState.None } // ── 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" -> 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" -> 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" -> 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" -> 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")) } } } }