refactor: WalletViewModel extract SendViewModel
This commit is contained in:
@@ -8,24 +8,18 @@ import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository
|
|||||||
import com.bitcointxoko.gudariwallet.data.NodeAliasRepository
|
import com.bitcointxoko.gudariwallet.data.NodeAliasRepository
|
||||||
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
|
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
|
||||||
import com.bitcointxoko.gudariwallet.data.WalletRepository
|
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.SendInputDetector
|
||||||
import com.bitcointxoko.gudariwallet.util.SendInputType
|
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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import com.bitcointxoko.gudariwallet.util.lnurlProtocolError
|
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
import com.bitcointxoko.gudariwallet.api.RouteHintHop
|
|
||||||
import com.bitcointxoko.gudariwallet.data.BalancePrefsStore
|
import com.bitcointxoko.gudariwallet.data.BalancePrefsStore
|
||||||
import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel
|
import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel
|
||||||
import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel
|
import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel
|
||||||
import com.bitcointxoko.gudariwallet.ui.receive.ReceiveViewModel
|
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"
|
private const val TAG = "WalletViewModel"
|
||||||
|
|
||||||
@@ -38,6 +32,7 @@ class WalletViewModel(
|
|||||||
val balanceVm : BalanceViewModel,
|
val balanceVm : BalanceViewModel,
|
||||||
val fiatVm : FiatViewModel,
|
val fiatVm : FiatViewModel,
|
||||||
val receiveVm : ReceiveViewModel,
|
val receiveVm : ReceiveViewModel,
|
||||||
|
val sendVm : SendViewModel,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
// ── Balance state ─────────────────────────────────────────────────────────
|
// ── Balance state ─────────────────────────────────────────────────────────
|
||||||
@@ -56,11 +51,19 @@ class WalletViewModel(
|
|||||||
fun resetReceiveState() = receiveVm.resetReceiveState()
|
fun resetReceiveState() = receiveVm.resetReceiveState()
|
||||||
fun executeWithdraw(k1: String, callback: String, amountSats: Long, memo: String) =
|
fun executeWithdraw(k1: String, callback: String, amountSats: Long, memo: String) =
|
||||||
receiveVm.executeWithdraw(k1, callback, amountSats, memo)
|
receiveVm.executeWithdraw(k1, callback, amountSats, memo)
|
||||||
|
val sendState: StateFlow<SendState> 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 ──────────────────────────────────────────────────────────
|
// ── Other states ──────────────────────────────────────────────────────────
|
||||||
private val _sendState = MutableStateFlow<SendState>(SendState.Idle)
|
|
||||||
val sendState: StateFlow<SendState> = _sendState
|
|
||||||
private val _lightningAddress = MutableStateFlow<String?>(balancePrefs.lightningAddress)
|
private val _lightningAddress = MutableStateFlow<String?>(balancePrefs.lightningAddress)
|
||||||
val lightningAddress: StateFlow<String?> = _lightningAddress
|
val lightningAddress: StateFlow<String?> = _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 ───────────────────────────────────────────────────────
|
// ── Clipboard offer ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
private var lastOfferedClipboard: String? = null
|
private var lastOfferedClipboard: String? = null
|
||||||
@@ -397,301 +146,4 @@ class WalletViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun refreshBalance() = balanceVm.refreshBalance()
|
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<String> {
|
|
||||||
// 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<RouteHintHop>,
|
|
||||||
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<String, String> {
|
|
||||||
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"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -8,12 +8,14 @@ import com.bitcointxoko.gudariwallet.data.BalancePrefsStore
|
|||||||
import com.bitcointxoko.gudariwallet.data.LNbitsNodeAliasRepository
|
import com.bitcointxoko.gudariwallet.data.LNbitsNodeAliasRepository
|
||||||
import com.bitcointxoko.gudariwallet.data.LNbitsWalletRepository
|
import com.bitcointxoko.gudariwallet.data.LNbitsWalletRepository
|
||||||
import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository
|
import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository
|
||||||
|
import com.bitcointxoko.gudariwallet.data.NodeAliasRepository
|
||||||
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
|
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
|
||||||
import com.bitcointxoko.gudariwallet.security.EncryptedSecretStore
|
import com.bitcointxoko.gudariwallet.security.EncryptedSecretStore
|
||||||
import com.bitcointxoko.gudariwallet.service.NodeAliasService
|
import com.bitcointxoko.gudariwallet.service.NodeAliasService
|
||||||
import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel
|
import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel
|
||||||
import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel
|
import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel
|
||||||
import com.bitcointxoko.gudariwallet.ui.receive.ReceiveViewModel
|
import com.bitcointxoko.gudariwallet.ui.receive.ReceiveViewModel
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.send.SendViewModel
|
||||||
|
|
||||||
class WalletViewModelFactory(private val app: Application) : ViewModelProvider.Factory {
|
class WalletViewModelFactory(private val app: Application) : ViewModelProvider.Factory {
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
@@ -21,27 +23,37 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
|
|||||||
val baseUrl = secrets.baseUrl.ifBlank { "https://placeholder.invalid" }
|
val baseUrl = secrets.baseUrl.ifBlank { "https://placeholder.invalid" }
|
||||||
val api = LNbitsClient.create(baseUrl)
|
val api = LNbitsClient.create(baseUrl)
|
||||||
val nodeAliasSvc = NodeAliasService(httpClient = LNbitsClient.httpClient, context = app)
|
val nodeAliasSvc = NodeAliasService(httpClient = LNbitsClient.httpClient, context = app)
|
||||||
|
val lnurlCache = LnurlCacheRepository(app)
|
||||||
|
val paymentCache = PaymentCacheRepository(app)
|
||||||
|
|
||||||
val aliasRepo = LNbitsNodeAliasRepository(nodeAliasSvc)
|
val aliasRepo = LNbitsNodeAliasRepository(nodeAliasSvc)
|
||||||
val repo = LNbitsWalletRepository(api = api, secrets = secrets, context = app)
|
val repo = LNbitsWalletRepository(api = api, secrets = secrets, context = app)
|
||||||
val balancePrefs = BalancePrefsStore(app)
|
val balancePrefs = BalancePrefsStore(app)
|
||||||
val balanceVm = BalanceViewModel(repo, balancePrefs)
|
val balanceVm = BalanceViewModel(repo, balancePrefs)
|
||||||
val fiatVm = FiatViewModel(repo, balancePrefs, balanceVm)
|
val fiatVm = FiatViewModel(repo, balancePrefs, balanceVm)
|
||||||
val receiveVm = ReceiveViewModel(repo)
|
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() }
|
balanceVm.onBalanceRefreshed = { fiatVm.onBalanceRefreshed() }
|
||||||
|
|
||||||
val lnurlCache = LnurlCacheRepository(app)
|
|
||||||
val paymentCache = PaymentCacheRepository(app)
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
return WalletViewModel(
|
return WalletViewModel(
|
||||||
repo,
|
repo,
|
||||||
aliasRepo,
|
aliasRepo = aliasRepo,
|
||||||
lnurlCache,
|
lnurlCache = lnurlCache,
|
||||||
paymentCache,
|
paymentCache = paymentCache,
|
||||||
balancePrefs,
|
balancePrefs = balancePrefs,
|
||||||
balanceVm,
|
balanceVm = balanceVm,
|
||||||
fiatVm,
|
fiatVm = fiatVm,
|
||||||
receiveVm
|
receiveVm = receiveVm,
|
||||||
|
sendVm = sendVm
|
||||||
) as T
|
) as T
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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>(SendState.Idle)
|
||||||
|
val sendState: StateFlow<SendState> = _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<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 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<RouteHintHop>,
|
||||||
|
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<String, String> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user