diff --git a/app/src/androidTest/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreenTest.kt index 1a179f2b0e..cb3457e756 100644 --- a/app/src/androidTest/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreenTest.kt @@ -34,12 +34,14 @@ class QuickPaySettingsScreenTest { QuickPaySettingsScreenContent( isQuickPayEnabled = true, quickPayAmount = 5, + quickPayDailyLimitMultiplier = 5, ) } } composeTestRule.onNodeWithTag("QuickpayToggle").assertIsDisplayed() - composeTestRule.onNodeWithTag("quickpay_amount_slider").assertIsDisplayed() + composeTestRule.onNodeWithTag("QuickpayAmountSlider").assertIsDisplayed() + composeTestRule.onNodeWithTag("QuickpayDailyLimitSlider").assertIsDisplayed() } @Test @@ -52,6 +54,7 @@ class QuickPaySettingsScreenTest { QuickPaySettingsScreenContent( isQuickPayEnabled = false, quickPayAmount = 5, + quickPayDailyLimitMultiplier = 5, onToggleQuickPay = { enabled -> toggleCalled = true toggleValue = enabled diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index dc337a825f..da37cc19a2 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -17,6 +17,7 @@ import to.bitkit.models.BalanceState import to.bitkit.models.FxRate import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.WalletScope +import to.bitkit.repositories.QuickPayLedger import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton @@ -164,6 +165,7 @@ data class AppCacheData( val backgroundReceive: NewTransactionSheetDetails? = null, val addressSearchLastUsedReceiveIndexes: Map = mapOf(), val addressSearchLastUsedChangeIndexes: Map = mapOf(), + val quickPayLedger: QuickPayLedger? = null, ) { fun isActivityDeleted(activityId: String, walletId: String): Boolean = scopedActivityId(walletId, activityId) in deletedActivities || diff --git a/app/src/main/java/to/bitkit/data/SettingsStore.kt b/app/src/main/java/to/bitkit/data/SettingsStore.kt index 5904e48815..eddec111d1 100644 --- a/app/src/main/java/to/bitkit/data/SettingsStore.kt +++ b/app/src/main/java/to/bitkit/data/SettingsStore.kt @@ -128,6 +128,7 @@ data class SettingsData( val bgPaymentsIntroSeen: Boolean = false, val isQuickPayEnabled: Boolean = false, val quickPayAmount: Int = 5, + val quickPayDailyLimitMultiplier: Int = 5, val lightningSetupStep: Int = 0, val isPinEnabled: Boolean = false, val isBiometricEnabled: Boolean = false, diff --git a/app/src/main/java/to/bitkit/di/RepoModule.kt b/app/src/main/java/to/bitkit/di/RepoModule.kt index 6cbcccb252..f08456fec0 100644 --- a/app/src/main/java/to/bitkit/di/RepoModule.kt +++ b/app/src/main/java/to/bitkit/di/RepoModule.kt @@ -5,8 +5,13 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import org.lightningdevkit.ldknode.Bolt11Invoice import to.bitkit.repositories.AmountInputHandler import to.bitkit.repositories.CurrencyRepo +import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.QuickPayInvoiceParser +import to.bitkit.repositories.QuickPayPaymentLookup +import to.bitkit.repositories.QuickPayReconcileRow import javax.inject.Named @Module @@ -22,5 +27,14 @@ abstract class RepoModule { @Provides @Named("enablePolling") fun provideEnablePolling(): Boolean = true + + @Provides + fun provideQuickPayInvoiceParser(): QuickPayInvoiceParser = QuickPayInvoiceParser { bolt11 -> + runCatching { Bolt11Invoice.fromStr(bolt11).paymentHash() }.getOrNull() + } + + @Provides + fun provideQuickPayPaymentLookup(lightningRepo: LightningRepo): QuickPayPaymentLookup = + QuickPayPaymentLookup { lightningRepo.listPaymentsOrNull()?.map { QuickPayReconcileRow(it) } } } } diff --git a/app/src/main/java/to/bitkit/models/Currency.kt b/app/src/main/java/to/bitkit/models/Currency.kt index 7925a03ed2..c74930db7e 100644 --- a/app/src/main/java/to/bitkit/models/Currency.kt +++ b/app/src/main/java/to/bitkit/models/Currency.kt @@ -76,6 +76,8 @@ data class ConvertedAmount( val sats: Long, val locale: Locale = Locale.getDefault(), ) { + fun toUsdCents(): Long = value.movePointRight(2).setScale(0, RoundingMode.HALF_UP).toLong() + val isSymbolSuffix: Boolean get() = currency in SUFFIX_SYMBOL_CURRENCIES data class BitcoinDisplayComponents( diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index c70c1c467b..406e16127b 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -278,8 +278,11 @@ class LightningRepo @Inject constructor( }.onFailure { // Cancellation is expected during pull-to-refresh, rethrow per Kotlin best practices if (it is CancellationException) throw it - - Logger.error("Error executing '$operationName'", it, context = TAG) + if (it is PaymentAbortedBeforeSend) { + Logger.debug("Aborted '$operationName' before dispatch", context = TAG) + } else { + Logger.error("Error executing '$operationName'", it, context = TAG) + } } private suspend fun setup( @@ -1257,13 +1260,24 @@ class LightningRepo @Inject constructor( suspend fun payInvoice( bolt11: String, sats: ULong? = null, + ): Result = payInvoice(bolt11, sats, onBeforeSend = { true }) + + suspend fun payInvoice( + bolt11: String, + sats: ULong? = null, + onBeforeSend: suspend () -> Boolean, ): Result = executeWhenNodeRunning("payInvoice") { waitForUsableChannels() + if (!onBeforeSend()) return@executeWhenNodeRunning Result.failure(PaymentAbortedBeforeSend()) runCatching { lightningService.send(bolt11, sats) }.also { syncState() } } + suspend fun listPaymentsOrNull(): List? = withContext(bgDispatcher) { + lightningService.listPayments() + } + suspend fun waitForUsableChannels() = withContext(bgDispatcher) { var state = _lightningState.value if (!state.nodeLifecycleState.canRun()) { @@ -2095,6 +2109,7 @@ class NodeConfigNotAppliedError : AppError("Node already running, requested conf class NodeRunTimeoutError(opName: String) : AppError("Timeout waiting for node to run and execute: '$opName'") class GetPaymentsError : AppError("It wasn't possible get the payments") class SyncUnhealthyError : AppError("Wallet sync failed before send") +class PaymentAbortedBeforeSend : AppError("Payment aborted before send") class LnurlPayInvoiceMismatchError : AppError("The invoice did not match the requested payment. Payment cancelled.") class PaymentRoutingRefreshTimeoutError : AppError("Timeout waiting for payment routing data refresh") diff --git a/app/src/main/java/to/bitkit/repositories/PendingPaymentRepo.kt b/app/src/main/java/to/bitkit/repositories/PendingPaymentRepo.kt index 349e9dc957..90b9cb1650 100644 --- a/app/src/main/java/to/bitkit/repositories/PendingPaymentRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PendingPaymentRepo.kt @@ -19,10 +19,12 @@ class PendingPaymentRepo @Inject constructor() { private val _state = MutableStateFlow(PendingPaymentsState()) val state = _state.asStateFlow() - private val _resolution = MutableSharedFlow(extraBufferCapacity = 1) + private val _resolution = MutableSharedFlow() val resolution = _resolution.asSharedFlow() + private val lastResolutions = MutableStateFlow>(emptyMap()) fun track(paymentHash: String) { + lastResolutions.update { it - paymentHash } _state.update { it.copy(pendingPayments = it.pendingPayments + paymentHash) } } @@ -30,9 +32,16 @@ class PendingPaymentRepo @Inject constructor() { suspend fun resolve(resolution: PendingPaymentResolution) { _state.update { it.copy(pendingPayments = it.pendingPayments - resolution.paymentHash) } + lastResolutions.update { it + (resolution.paymentHash to resolution) } _resolution.emit(resolution) } + fun consumeResolution(paymentHash: String): PendingPaymentResolution? { + val taken = lastResolutions.value[paymentHash] ?: return null + lastResolutions.update { it - paymentHash } + return taken + } + fun setActiveHash(hash: String?) = _state.update { it.copy(activeHash = hash) } fun isActive(hash: String): Boolean = _state.value.activeHash == hash @@ -48,7 +57,11 @@ class PaymentPendingException(val paymentHash: String) : AppError("Payment pendi sealed interface PendingPaymentResolution { val paymentHash: String - data class Success(override val paymentHash: String) : PendingPaymentResolution + data class Success( + override val paymentHash: String, + val amountWithFeeSats: Long? = null, + ) : PendingPaymentResolution + data class Failure( override val paymentHash: String, val reason: PaymentFailureReason? = null, diff --git a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt new file mode 100644 index 0000000000..4cc1a29c3d --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt @@ -0,0 +1,1096 @@ +package to.bitkit.repositories + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.lightningdevkit.ldknode.NodeException +import org.lightningdevkit.ldknode.PaymentDetails +import org.lightningdevkit.ldknode.PaymentDirection +import org.lightningdevkit.ldknode.PaymentFailureReason +import org.lightningdevkit.ldknode.PaymentKind +import org.lightningdevkit.ldknode.PaymentStatus +import to.bitkit.async.appScope +import to.bitkit.data.AppCacheData +import to.bitkit.data.CacheStore +import to.bitkit.data.SettingsStore +import to.bitkit.di.IoDispatcher +import to.bitkit.ext.callbackAmountMsats +import to.bitkit.ext.runSuspendCatching +import to.bitkit.ext.supportPaymentRequest +import to.bitkit.models.USD +import to.bitkit.models.msatFloorOf +import to.bitkit.models.safe +import to.bitkit.utils.AppError +import to.bitkit.utils.Logger +import to.bitkit.utils.asNodeException +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.coroutineContext +import kotlin.time.Clock + +fun interface QuickPayInvoiceParser { + fun parse(bolt11: String): String? +} + +fun interface QuickPayPaymentLookup { + suspend fun rows(): List? +} + +data class QuickPaySession(val id: String = UUID.randomUUID().toString()) + +sealed interface QuickPayPayRequest { + val amountSats: ULong + + data class Bolt11( + val bolt11: String, + override val amountSats: ULong, + ) : QuickPayPayRequest + + data class LnurlPay( + val data: com.synonym.bitkitcore.LnurlPayData, + override val amountSats: ULong, + ) : QuickPayPayRequest +} + +sealed interface QuickPaySessionEvent { + data class Success( + val paymentHash: String, + val amountWithFee: Long, + ) : QuickPaySessionEvent + + data class Pending( + val paymentHash: String, + val amount: Long, + val paymentRequest: String, + ) : QuickPaySessionEvent + + data object FallBackToConfirm : QuickPaySessionEvent + + data class Error( + val error: Throwable, + val paymentRequest: String?, + ) : QuickPaySessionEvent +} + +enum class QuickPayCompletionKind { + NONE, + SETTLED_SUCCESS, + SETTLED_FAILURE, +} + +data class QuickPayCompletionOutcome( + val kind: QuickPayCompletionKind = QuickPayCompletionKind.NONE, + val invoicePaymentHash: String? = null, +) { + val wasQuickPay: Boolean get() = kind != QuickPayCompletionKind.NONE + + companion object { + val None = QuickPayCompletionOutcome() + } +} + +class QuickPayConversionError : AppError("Currency conversion failed") + +class QuickPayPaymentFailedError( + val paymentHash: String, + val reason: PaymentFailureReason?, + val paymentRequest: String?, +) : AppError(reason?.name) + +@Singleton +@Suppress("LongParameterList", "LargeClass") +class QuickPayRepo @Inject constructor( + cacheStore: CacheStore, + private val settingsStore: SettingsStore, + private val currencyRepo: CurrencyRepo, + private val lightningRepo: LightningRepo, + private val pendingPaymentRepo: PendingPaymentRepo, + private val invoiceParser: QuickPayInvoiceParser, + private val paymentLookup: QuickPayPaymentLookup, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + clock: Clock, +) { + companion object { + private const val TAG = "QuickPayRepo" + } + + private val spend = QuickPaySpendStore(cacheStore, clock) + private val scope = appScope(ioDispatcher, TAG) + private val mutex = Mutex() + private val opsByKey = mutableMapOf() + private val sessionFlows = ConcurrentHashMap>() + + init { + scope.launch { + lightningRepo.lightningState + .map { it.nodeLifecycleState.isRunning() to it.isSyncHealthy } + .distinctUntilChanged() + .collect { (running, _) -> + if (running) reconcileAgainstLdk() + } + } + } + + fun attach(session: QuickPaySession): Flow { + val flow = MutableSharedFlow(extraBufferCapacity = 8) + sessionFlows[session.id] = flow + return flow + } + + fun detach(session: QuickPaySession) { + scope.launch { detachSession(session.id) } + } + + fun detachAll() { + scope.launch { + val ids = sessionFlows.keys.toList() + ids.forEach { detachSession(it) } + } + } + + fun pay(session: QuickPaySession, request: QuickPayPayRequest) { + scope.launch { payNow(session, request) } + } + + suspend fun canApply(amountSats: ULong): Result = withContext(ioDispatcher) { + runSuspendCatching { + val settings = settingsStore.data.first() + if (!settings.isQuickPayEnabled || amountSats == 0uL) return@runSuspendCatching false + + val thresholdSats = currencyRepo.convertFiatToSats( + settings.quickPayAmount.toDouble(), + USD, + ).getOrNull() ?: return@runSuspendCatching false + if (amountSats > thresholdSats) return@runSuspendCatching false + + val converted = currencyRepo.convertSatsToFiat(amountSats.toLong(), USD).getOrNull() + ?: return@runSuspendCatching false + val reserveCents = quickPayReserveCents(converted.toUsdCents(), settings.quickPayAmount, amountSats) + val capCents = quickPayCapCents(settings.quickPayAmount, settings.quickPayDailyLimitMultiplier) + val snapshot = mutex.withLock { spend.snapshot() } + if (!snapshot.supported) return@runSuspendCatching false + if (snapshot.spentCents + reserveCents <= capCents) return@runSuspendCatching true + + Logger.info( + "Skipping QuickPay: daily spend '${snapshot.spentCents}' + '$reserveCents' exceeds cap '$capCents'", + context = TAG, + ) + false + } + } + + internal suspend fun reserveBound( + paymentHash: String, + amountSats: ULong, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + if (paymentHash.isBlank()) return@runSuspendCatching null + val prepared = prepareReserve(amountSats) ?: return@runSuspendCatching null + mutex.withLock { + spend.reserve( + paymentHash, + prepared.amountCents, + prepared.capCents, + opsByKey.values.map { it.invoiceHash }.toSet(), + ) + } + } + } + + suspend fun signalCompletion( + paymentId: String?, + paymentHash: String?, + success: Boolean, + feePaidMsat: ULong? = null, + failureReason: PaymentFailureReason? = null, + ): QuickPayCompletionOutcome = withContext(ioDispatcher) { + mutex.withLock { + signalCompletionLocked( + paymentId = paymentId, + paymentHash = paymentHash, + success = success, + feePaidMsat = feePaidMsat, + failureReason = failureReason, + ) + } + } + + internal suspend fun reconcileAgainstLdk() { + val rows = loadPaymentRows() + mutex.withLock { reconcileLocked(rows) } + } + + internal suspend fun payNow(session: QuickPaySession, request: QuickPayPayRequest) { + val invoice = resolveInvoice(session, request) ?: return + val invoiceHash = invoiceHashOrEmit(session, invoice) ?: return + when (preparePay(session, invoice, invoiceHash)) { + PreparePayResult.LIVE -> replayLive(invoiceHash) + PreparePayResult.REJECTED -> return + PreparePayResult.RECOVERED -> settleRecovered(invoiceHash) + PreparePayResult.FRESH -> { + dispatchBolt11(invoice, invoiceHash) + awaitCompletionOrPending(invoiceHash) + } + } + } + + internal suspend fun hasOpen(paymentHash: String): Boolean = mutex.withLock { + opsByKey[paymentHash] != null || spend.matching(paymentHash) != null + } + + private suspend fun replayLive(invoiceHash: String) { + mutex.withLock { + val op = opsByKey[invoiceHash] ?: return@withLock + if (!op.emitted) return@withLock + emitToSession( + op.sessionId, + QuickPaySessionEvent.Pending( + paymentHash = op.invoiceHash, + amount = op.displaySats.toLong(), + paymentRequest = op.paymentRequest, + ), + ) + } + } + + private fun invoiceHashOrEmit(session: QuickPaySession, invoice: ResolvedInvoice): String? { + val invoiceHash = invoiceParser.parse(invoice.bolt11) + if (invoiceHash != null) return invoiceHash + emitToSession( + session.id, + QuickPaySessionEvent.Error( + invoice.parseError ?: QuickPayConversionError(), + invoice.bolt11, + ), + ) + return null + } + + private suspend fun preparePay( + session: QuickPaySession, + invoice: ResolvedInvoice, + invoiceHash: String, + ): PreparePayResult { + val prepared = runSuspendCatching { prepareReserve(invoice.amountSats) }.getOrElse { + emitToSession(session.id, QuickPaySessionEvent.Error(it, invoice.bolt11)) + return PreparePayResult.REJECTED + } + return mutex.withLock { + val existing = opsByKey[invoiceHash] + if (existing != null) { + existing.sessionId = session.id + return@withLock PreparePayResult.LIVE + } + val open = spend.matching(invoiceHash) + if (open != null) { + registerOp(recoveredOp(session, invoice, invoiceHash, open)) + return@withLock PreparePayResult.RECOVERED + } + val keepHashes = opsByKey.values.map { it.invoiceHash }.toSet() + if (prepared == null || + spend.reserve(invoiceHash, prepared.amountCents, prepared.capCents, keepHashes) == null + ) { + rejectCap(session, invoice) + return@withLock PreparePayResult.REJECTED + } + if (sessionFlows[session.id] == null) { + spend.release(invoiceHash) + return@withLock PreparePayResult.REJECTED + } + registerOp( + InFlightOp( + invoiceHash = invoiceHash, + displaySats = invoice.amountSats, + paymentRequest = invoice.bolt11, + dispatched = false, + sessionId = session.id, + job = coroutineContext[Job], + paymentId = null, + ), + ) + PreparePayResult.FRESH + } + } + + private suspend fun settleRecovered(invoiceHash: String) { + val rows = loadPaymentRows() + mutex.withLock { + val op = opsByKey[invoiceHash] ?: return@withLock + val record = spend.matching(invoiceHash) ?: run { + emitPendingLocked(op) + return@withLock + } + val match = rows?.let { pickLedgerMatch(record, it) } + when (match?.status) { + QuickPayReconcileRow.Status.SUCCEEDED -> { + signalCompletionLocked( + paymentId = record.paymentId, + paymentHash = invoiceHash, + success = true, + ) + } + QuickPayReconcileRow.Status.FAILED -> { + val outcome = signalCompletionLocked( + paymentId = record.paymentId, + paymentHash = invoiceHash, + success = false, + ) + if (outcome.kind == QuickPayCompletionKind.NONE) { + emitPendingLocked(op) + } + } + else -> emitPendingLocked(op) + } + } + } + + private suspend fun dispatchBolt11(invoice: ResolvedInvoice, invoiceHash: String) { + lightningRepo.payInvoice(bolt11 = invoice.bolt11, sats = null) { + tryMarkDispatched(invoiceHash) + }.fold( + onSuccess = { onInvoiceAccepted(invoiceHash, it) }, + onFailure = { onInvoiceRejected(invoiceHash, invoice.bolt11, it) }, + ) + } + + private suspend fun tryMarkDispatched(invoiceHash: String): Boolean = mutex.withLock { + val current = opsByKey[invoiceHash] ?: return@withLock false + if (current.cancelBeforeDispatch) return@withLock false + current.dispatched = true + true + } + + private suspend fun onInvoiceAccepted(invoiceHash: String, paymentId: String) { + mutex.withLock { + spend.markSubmitted(invoiceHash, paymentId) + val current = opsByKey[invoiceHash] ?: return@withLock + current.paymentId = paymentId + if (paymentId.isNotBlank() && paymentId != invoiceHash) { + opsByKey[paymentId] = current + } + } + } + + private suspend fun onInvoiceRejected( + invoiceHash: String, + paymentRequest: String, + error: Throwable, + ) { + if (error is PaymentAbortedBeforeSend) { + releaseIfNotDispatched(invoiceHash) + return + } + handleDispatchError(invoiceHash, paymentRequest, error) + } + + private suspend fun awaitCompletionOrPending(invoiceHash: String) { + val current = mutex.withLock { opsByKey[invoiceHash] } ?: return + withTimeoutOrNull(LightningRepo.SEND_LN_TIMEOUT) { + current.settled.await() + } + mutex.withLock { + val live = opsByKey[invoiceHash] ?: return@withLock + emitPendingLocked(live) + } + } + + private suspend fun releaseIfNotDispatched(invoiceHash: String) { + mutex.withLock { + val current = opsByKey[invoiceHash] + if (current == null || current.dispatched) return@withLock + spend.release(invoiceHash) + removeOpLocked(current) + } + } + + private suspend fun resolveInvoice( + session: QuickPaySession, + request: QuickPayPayRequest, + ): ResolvedInvoice? { + return when (request) { + is QuickPayPayRequest.Bolt11 -> ResolvedInvoice( + bolt11 = request.bolt11, + amountSats = request.amountSats, + parseError = null, + ) + is QuickPayPayRequest.LnurlPay -> { + lightningRepo.fetchLnurlInvoice( + data = request.data, + amountMsats = request.data.callbackAmountMsats(request.amountSats), + ).fold( + onSuccess = { ResolvedInvoice(it.bolt11, request.amountSats, null) }, + onFailure = { + if (sessionFlows[session.id] != null) { + emitToSession( + session.id, + QuickPaySessionEvent.Error(it, request.data.supportPaymentRequest()), + ) + } + null + }, + ) + } + } + } + + private suspend fun handleDispatchError( + invoiceHash: String, + paymentRequest: String, + error: Throwable, + ) { + when (val kind = classifyDispatchError(error)) { + QuickPayDispatchClass.PRE_DISPATCH_REJECTION -> { + mutex.withLock { + signalCompletionLocked( + paymentId = null, + paymentHash = invoiceHash, + success = false, + ) + emitOutcome(invoiceHash, error, paymentRequest) + } + } + QuickPayDispatchClass.DUPLICATE_PAYMENT, + QuickPayDispatchClass.AMBIGUOUS, + -> { + val rows = loadPaymentRows() + mutex.withLock { + settleAmbiguousLocked( + invoiceHash = invoiceHash, + paymentRequest = paymentRequest, + error = error, + rows = rows, + duplicate = kind == QuickPayDispatchClass.DUPLICATE_PAYMENT, + ) + } + } + } + } + + private suspend fun settleAmbiguousLocked( + invoiceHash: String, + paymentRequest: String, + error: Throwable, + rows: List?, + duplicate: Boolean, + ) { + val record = spend.matching(invoiceHash) + val applied = if (record != null && rows != null) { + applyAmbiguousLookupLocked(record, rows, duplicate) + } else { + AmbiguousApply.UNCHANGED + } + val remaining = spend.matching(invoiceHash) + val op = opsByKey[invoiceHash] + if (remaining != null) { + op?.dispatched = true + op?.let { emitPendingLocked(it) } + return + } + if (op == null) return + when (applied) { + AmbiguousApply.SUCCEEDED -> emitSuccessLocked(op, feePaidMsat = null) + AmbiguousApply.FAILED, + AmbiguousApply.UNCHANGED, + -> emitErrorLocked(op, error, paymentRequest) + } + removeOpLocked(op) + } + + private suspend fun detachSession(sessionId: String) { + mutex.withLock { + sessionFlows.remove(sessionId) + val op = opsByKey.values.firstOrNull { it.sessionId == sessionId } ?: return@withLock + if (op.sessionId != sessionId) return@withLock + op.sessionId = null + if (op.dispatched) return@withLock + op.cancelBeforeDispatch = true + op.job?.cancel() + spend.release(op.invoiceHash) + removeOpLocked(op) + } + } + + @Suppress("CyclomaticComplexMethod", "ReturnCount") + private suspend fun signalCompletionLocked( + paymentId: String?, + paymentHash: String?, + success: Boolean, + feePaidMsat: ULong? = null, + failureReason: PaymentFailureReason? = null, + ): QuickPayCompletionOutcome { + val keys = listOfNotNull(paymentId, paymentHash).filter { it.isNotBlank() } + if (keys.isEmpty()) return QuickPayCompletionOutcome.None + + val snapshot = spend.snapshot() + if (!snapshot.supported) return QuickPayCompletionOutcome.None + val ledger = snapshot.ledger ?: return QuickPayCompletionOutcome.None + val index = keys.firstNotNullOfOrNull { ledger.recordIndex(it) } ?: return QuickPayCompletionOutcome.None + val record = ledger.records[index] + val op = opsByKey[record.invoicePaymentHash] ?: record.paymentId?.let { opsByKey[it] } + if (!success && !isAttributedFailure(record, op, paymentId, paymentHash)) { + return QuickPayCompletionOutcome.None + } + + spend.settle(keys, success) + + val kind = if (success) { + QuickPayCompletionKind.SETTLED_SUCCESS + } else { + QuickPayCompletionKind.SETTLED_FAILURE + } + val outcome = QuickPayCompletionOutcome( + kind = kind, + invoicePaymentHash = record.invoicePaymentHash, + ) + if (op != null) { + if (success) { + emitSuccessLocked(op, feePaidMsat) + } else { + emitErrorLocked( + op, + QuickPayPaymentFailedError( + paymentHash = record.invoicePaymentHash, + reason = failureReason, + paymentRequest = op.paymentRequest, + ), + op.paymentRequest, + ) + } + removeOpLocked(op) + } + return outcome + } + + private fun isAttributedFailure( + record: QuickPayLedgerRecord, + op: InFlightOp?, + paymentId: String?, + paymentHash: String?, + ): Boolean { + if (record.paymentId != null && (record.paymentId == paymentId || record.paymentId == paymentHash)) { + return true + } + if (op?.dispatched == true && + (paymentHash == record.invoicePaymentHash || paymentId == record.invoicePaymentHash) + ) { + return true + } + if (record.phase == QuickPayRecordPhase.SUBMITTED && + (paymentHash == record.invoicePaymentHash || paymentId == record.invoicePaymentHash) + ) { + return true + } + return false + } + + private suspend fun applyAmbiguousLookupLocked( + record: QuickPayLedgerRecord, + rows: List, + duplicate: Boolean, + ): AmbiguousApply { + val match = pickLedgerMatch(record, rows) ?: return AmbiguousApply.UNCHANGED + return when (match.status) { + QuickPayReconcileRow.Status.PENDING -> AmbiguousApply.UNCHANGED + QuickPayReconcileRow.Status.SUCCEEDED -> { + if (duplicate && + record.phase == QuickPayRecordPhase.SUBMITTING && + record.paymentId == null + ) { + spend.release(record.invoicePaymentHash) + } else { + spend.drop(record.invoicePaymentHash) + } + AmbiguousApply.SUCCEEDED + } + QuickPayReconcileRow.Status.FAILED -> { + val attributed = isAttributedFailure( + record, + opsByKey[record.invoicePaymentHash], + match.paymentId, + match.invoicePaymentHash, + ) + if (!attributed) { + return AmbiguousApply.UNCHANGED + } + spend.release(record.invoicePaymentHash) + AmbiguousApply.FAILED + } + } + } + + private suspend fun reconcileLocked(rows: List?) { + val live = opsByKey.values.map { it.invoiceHash }.toSet() + spend.applyReconcile(rows, live) { record, match -> + isAttributedFailure(record, opsByKey[record.invoicePaymentHash], match.paymentId, match.invoicePaymentHash) + } + } + + private fun recoveredOp( + session: QuickPaySession, + invoice: ResolvedInvoice, + invoiceHash: String, + open: QuickPayLedgerRecord, + ) = InFlightOp( + invoiceHash = invoiceHash, + displaySats = invoice.amountSats, + paymentRequest = invoice.bolt11, + dispatched = true, + sessionId = session.id, + job = null, + paymentId = open.paymentId, + ) + + private fun rejectCap(session: QuickPaySession, invoice: ResolvedInvoice) { + Logger.info( + "Skipping QuickPay pay: daily spend reserve failed for '${invoice.amountSats}'", + context = TAG, + ) + emitToSession(session.id, QuickPaySessionEvent.FallBackToConfirm) + } + + private fun registerOp(op: InFlightOp) { + opsByKey[op.invoiceHash] = op + op.paymentId?.takeIf { it.isNotBlank() && it != op.invoiceHash }?.let { opsByKey[it] = op } + } + + private fun removeOpLocked(op: InFlightOp) { + opsByKey.entries.removeAll { it.value === op } + } + + private fun emitOutcome( + invoiceHash: String, + error: Throwable, + paymentRequest: String, + ) { + val op = opsByKey[invoiceHash] ?: return + emitErrorLocked(op, error, paymentRequest) + removeOpLocked(op) + } + + private fun emitPendingLocked(op: InFlightOp) { + if (op.settled.isCompleted || op.emitted) return + val sessionId = op.sessionId ?: return + op.emitted = true + pendingPaymentRepo.track(op.invoiceHash) + emitToSession( + sessionId, + QuickPaySessionEvent.Pending( + paymentHash = op.invoiceHash, + amount = op.displaySats.toLong(), + paymentRequest = op.paymentRequest, + ), + ) + op.settled.complete(Unit) + } + + private fun emitSuccessLocked(op: InFlightOp, feePaidMsat: ULong?) { + if (op.emitted) { + op.settled.complete(Unit) + return + } + op.emitted = true + val feeSats = msatFloorOf(feePaidMsat ?: 0u) + emitToSession( + op.sessionId, + QuickPaySessionEvent.Success( + paymentHash = op.invoiceHash, + amountWithFee = (op.displaySats.safe() + feeSats.safe()).toLong(), + ), + ) + op.settled.complete(Unit) + } + + private fun emitErrorLocked(op: InFlightOp, error: Throwable, paymentRequest: String?) { + if (op.emitted) { + op.settled.complete(Unit) + return + } + op.emitted = true + emitToSession(op.sessionId, QuickPaySessionEvent.Error(error, paymentRequest)) + op.settled.complete(Unit) + } + + private suspend fun prepareReserve(amountSats: ULong): PreparedReserve? { + val settings = settingsStore.data.first() + val thresholdSats = currencyRepo.convertFiatToSats( + settings.quickPayAmount.toDouble(), + USD, + ).getOrNull() + if (thresholdSats == null || thresholdSats == 0uL || amountSats > thresholdSats) { + return null + } + val converted = currencyRepo.convertSatsToFiat(amountSats.toLong(), USD).getOrElse { + throw QuickPayConversionError() + } + val amountCents = quickPayReserveCents(converted.toUsdCents(), settings.quickPayAmount, amountSats) + val capCents = quickPayCapCents(settings.quickPayAmount, settings.quickPayDailyLimitMultiplier) + return PreparedReserve(amountCents, capCents) + } + + private suspend fun loadPaymentRows(): List? = + runSuspendCatching { paymentLookup.rows() }.getOrNull() + + private fun emitToSession(sessionId: String?, event: QuickPaySessionEvent) { + if (sessionId == null) return + sessionFlows[sessionId]?.tryEmit(event) + } + + private data class InFlightOp( + val invoiceHash: String, + val displaySats: ULong, + val paymentRequest: String, + var dispatched: Boolean, + var sessionId: String?, + var job: Job?, + var paymentId: String?, + var cancelBeforeDispatch: Boolean = false, + var emitted: Boolean = false, + val settled: CompletableDeferred = CompletableDeferred(), + ) + + private data class ResolvedInvoice( + val bolt11: String, + val amountSats: ULong, + val parseError: Throwable?, + ) + + private data class PreparedReserve( + val amountCents: Long, + val capCents: Long, + ) + + private enum class PreparePayResult { LIVE, RECOVERED, FRESH, REJECTED } + + private enum class AmbiguousApply { UNCHANGED, SUCCEEDED, FAILED } +} + +internal class QuickPaySpendStore( + private val cacheStore: CacheStore, + private val clock: Clock, +) { + companion object { + const val LEDGER_VERSION = 1 + } + + suspend fun snapshot(): SpendSnapshot { + val data = cacheStore.data.first() + val (ledger, supported) = data.resolvedLedger() + val dayKey = currentDayKey() + if (!supported) return SpendSnapshot(0L, supported = false, ledger = ledger) + val spend = spendFor(ledger, dayKey) + return SpendSnapshot(spend.spentCents, supported = true, ledger = ledger) + } + + suspend fun matching(hash: String): QuickPayLedgerRecord? { + val (ledger, supported) = cacheStore.data.first().resolvedLedger() + if (!supported) return null + return ledger.recordMatching(hash) + } + + suspend fun reserve( + paymentHash: String, + amountCents: Long, + capCents: Long, + keepHashes: Set = emptySet(), + ): QuickPayLedgerRecord? { + var reserved: QuickPayLedgerRecord? = null + val wrote = writeLedger { ledger, dayKey -> + if (ledger.recordMatching(paymentHash) != null) return@writeLedger ledger + val spend = spendFor(ledger, dayKey) + if (spend.spentCents > Long.MAX_VALUE - amountCents) return@writeLedger ledger + val total = spend.spentCents + amountCents + if (total > capCents) return@writeLedger ledger + val next = ledger.pruned(spend.dayKey, keepHashes) + val record = QuickPayLedgerRecord( + id = UUID.randomUUID().toString(), + amountCents = amountCents, + dayKey = spend.dayKey, + invoicePaymentHash = paymentHash, + paymentId = null, + phase = QuickPayRecordPhase.SUBMITTING, + ) + reserved = record + next.copy( + dayKey = spend.dayKey, + spentCents = total, + records = next.records + record, + ) + } + return if (!wrote) null else reserved + } + + suspend fun release(paymentHash: String) { + writeLedger { ledger, _ -> releaseRecord(ledger, paymentHash) } + } + + suspend fun drop(paymentHash: String) { + writeLedger { ledger, _ -> + val index = ledger.recordIndex(paymentHash) ?: return@writeLedger ledger + ledger.copy(records = ledger.records.toMutableList().also { it.removeAt(index) }) + } + } + + suspend fun markSubmitted(invoiceHash: String, paymentId: String) { + writeLedger { ledger, _ -> + val index = ledger.recordIndex(invoiceHash) ?: return@writeLedger ledger + val record = ledger.records[index] + ledger.copy( + records = ledger.records.toMutableList().also { + it[index] = record.copy( + paymentId = paymentId, + phase = QuickPayRecordPhase.SUBMITTED, + ) + }, + ) + } + } + + suspend fun settle(keys: List, success: Boolean) { + writeLedger { current, _ -> + val i = keys.firstNotNullOfOrNull { current.recordIndex(it) } ?: return@writeLedger current + val found = current.records[i] + val remaining = current.records.toMutableList().also { it.removeAt(i) } + val spent = if (!success && found.dayKey == current.dayKey) { + (current.spentCents - found.amountCents).coerceAtLeast(0L) + } else { + current.spentCents + } + current.copy(records = remaining, spentCents = spent) + } + } + + @Suppress("LoopWithTooManyJumpStatements") + suspend fun applyReconcile( + rows: List?, + liveSubmittingHashes: Set, + shouldReleaseFailed: (QuickPayLedgerRecord, QuickPayReconcileRow) -> Boolean, + ) { + if (rows == null) return + writeLedger { ledger, dayKey -> + val next = ledger.pruned(dayKey, liveSubmittingHashes) + val remaining = mutableListOf() + var spent = next.spentCents + for (record in next.records) { + if (liveSubmittingHashes.contains(record.invoicePaymentHash)) { + remaining.add(record) + continue + } + val match = pickLedgerMatch(record, rows) + if (match == null) { + remaining.add(record) + continue + } + when (match.status) { + QuickPayReconcileRow.Status.PENDING -> remaining.add(record) + QuickPayReconcileRow.Status.SUCCEEDED -> Unit + QuickPayReconcileRow.Status.FAILED -> { + if (!shouldReleaseFailed(record, match)) { + remaining.add(record) + } else if (record.dayKey == next.dayKey) { + spent = (spent - record.amountCents).coerceAtLeast(0L) + } + } + } + } + next.copy(records = remaining, spentCents = spent) + } + } + + private suspend fun writeLedger(transform: (QuickPayLedger, String) -> QuickPayLedger): Boolean { + var supported = true + cacheStore.update { data -> + val (ledger, ok) = data.resolvedLedger() + if (!ok) { + supported = false + return@update data + } + val dayKey = currentDayKey() + val next = transform(ledger, dayKey) + data.copy(quickPayLedger = next) + } + return supported + } + + private fun currentDayKey(): String = + clock.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.toString() +} + +internal data class SpendSnapshot( + val spentCents: Long, + val supported: Boolean, + val ledger: QuickPayLedger?, +) + +internal fun classifyDispatchError(error: Throwable): QuickPayDispatchClass { + return when (error.asNodeException()) { + is NodeException.InvalidInvoice, + is NodeException.InvalidAmount, + is NodeException.InvalidPaymentHash, + is NodeException.InvalidPaymentId, + is NodeException.InvalidNetwork, + -> QuickPayDispatchClass.PRE_DISPATCH_REJECTION + is NodeException.DuplicatePayment -> QuickPayDispatchClass.DUPLICATE_PAYMENT + else -> QuickPayDispatchClass.AMBIGUOUS + } +} + +enum class QuickPayDispatchClass { + PRE_DISPATCH_REJECTION, + DUPLICATE_PAYMENT, + AMBIGUOUS, +} + +data class QuickPayReconcileRow( + val paymentId: String, + val invoicePaymentHash: String, + val isOutboundBolt11: Boolean, + val status: Status, +) { + enum class Status { SUCCEEDED, FAILED, PENDING } + + constructor(payment: PaymentDetails) : this( + paymentId = payment.id, + invoicePaymentHash = when (val kind = payment.kind) { + is PaymentKind.Bolt11 -> kind.hash + else -> payment.id + }, + isOutboundBolt11 = payment.direction == PaymentDirection.OUTBOUND && payment.kind is PaymentKind.Bolt11, + status = when (payment.status) { + PaymentStatus.SUCCEEDED -> Status.SUCCEEDED + PaymentStatus.FAILED -> Status.FAILED + PaymentStatus.PENDING -> Status.PENDING + }, + ) +} + +@Serializable +enum class QuickPayRecordPhase { + @SerialName("submitting") + SUBMITTING, + + @SerialName("submitted") + SUBMITTED, +} + +@Serializable +data class QuickPayLedgerRecord( + val id: String, + val amountCents: Long, + val dayKey: String, + val invoicePaymentHash: String, + val paymentId: String? = null, + val phase: QuickPayRecordPhase, +) + +@Serializable +data class QuickPayLedger( + val version: Int, + val dayKey: String, + val spentCents: Long, + val records: List = emptyList(), +) + +private fun quickPayCapCents(thresholdUsd: Int, multiplier: Int): Long = + thresholdUsd.toLong() * 100L * multiplier.toLong() + +private fun quickPayReserveCents( + convertedCents: Long, + thresholdUsd: Int, + amountSats: ULong, +): Long { + val clamped = minOf(convertedCents, thresholdUsd.toLong() * 100L) + if (amountSats == 0uL) return clamped + return maxOf(clamped, 1L) +} + +private data class QuickPayDaySpend( + val dayKey: String, + val spentCents: Long, +) + +private fun AppCacheData.resolvedLedger(): Pair { + val ledger = quickPayLedger + if (ledger != null) { + return ledger to (ledger.version == QuickPaySpendStore.LEDGER_VERSION) + } + return QuickPayLedger( + version = QuickPaySpendStore.LEDGER_VERSION, + dayKey = "", + spentCents = 0L, + records = emptyList(), + ) to true +} + +private fun QuickPayLedger.recordMatching(hash: String): QuickPayLedgerRecord? = + records.find { it.invoicePaymentHash == hash || it.paymentId == hash || it.id == hash } + +private fun QuickPayLedger.recordIndex(hash: String): Int? = + records.indexOfFirst { it.invoicePaymentHash == hash || it.paymentId == hash || it.id == hash } + .takeIf { it >= 0 } + +private fun QuickPayLedger.pruned( + currentDay: String, + keepHashes: Set = emptySet(), +): QuickPayLedger { + if (currentDay.isEmpty()) return this + return copy( + records = records.filter { it.dayKey >= currentDay || it.invoicePaymentHash in keepHashes }, + ) +} + +private fun spendFor(ledger: QuickPayLedger, dayKey: String): QuickPayDaySpend = when { + ledger.dayKey.isEmpty() || dayKey > ledger.dayKey -> QuickPayDaySpend(dayKey, 0L) + dayKey == ledger.dayKey -> QuickPayDaySpend(dayKey, ledger.spentCents) + else -> QuickPayDaySpend(ledger.dayKey, ledger.spentCents) +} + +private fun releaseRecord(ledger: QuickPayLedger, paymentHash: String): QuickPayLedger { + val index = ledger.recordIndex(paymentHash) ?: return ledger + val record = ledger.records[index] + val remaining = ledger.records.toMutableList().also { it.removeAt(index) } + val spent = if (record.dayKey == ledger.dayKey) { + (ledger.spentCents - record.amountCents).coerceAtLeast(0L) + } else { + ledger.spentCents + } + return ledger.copy(records = remaining, spentCents = spent) +} + +private fun pickLedgerMatch( + record: QuickPayLedgerRecord, + rows: List, +): QuickPayReconcileRow? { + val matches = rows.filter { row -> + row.isOutboundBolt11 && ( + row.invoicePaymentHash == record.invoicePaymentHash || + row.paymentId == record.invoicePaymentHash || + row.paymentId == record.paymentId || + (record.paymentId != null && row.invoicePaymentHash == record.paymentId) + ) + } + if (matches.isEmpty()) return null + record.paymentId?.let { pid -> matches.find { it.paymentId == pid } }?.let { return it } + return matches.maxBy { + when (it.status) { + QuickPayReconcileRow.Status.SUCCEEDED -> 2 + QuickPayReconcileRow.Status.PENDING -> 1 + QuickPayReconcileRow.Status.FAILED -> 0 + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/components/Slider.kt b/app/src/main/java/to/bitkit/ui/components/Slider.kt index 8c6d124055..67f6bafffe 100644 --- a/app/src/main/java/to/bitkit/ui/components/Slider.kt +++ b/app/src/main/java/to/bitkit/ui/components/Slider.kt @@ -4,7 +4,7 @@ import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.SpringSpec import androidx.compose.foundation.Canvas import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -21,8 +21,10 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -31,10 +33,19 @@ import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.setProgress +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.ImmutableList @@ -52,201 +63,280 @@ private const val SLIDER_EDGE_INSET_DP = 16 private const val TRACK_HEIGHT_DP = 8 private const val STEP_MARKER_WIDTH_DP = 4 private const val STEP_MARKER_HEIGHT_DP = 16 +private const val LABEL_TOP_PADDING_DP = 4 @Suppress("CyclomaticComplexMethod") @Composable -fun StepSlider( +fun Slider( value: Int, steps: ImmutableList, onValueChange: (Int) -> Unit, modifier: Modifier = Modifier, + formatLabel: (Int) -> String = { "$$it" }, ) { val density = LocalDensity.current val coroutineScope = rememberCoroutineScope() - - var sliderWidth by remember { mutableIntStateOf(0) } val knobPosition = remember { Animatable(0f) } + var isDragging by remember { mutableStateOf(false) } + var layoutWidthPx by remember { mutableIntStateOf(0) } + val knobHeightPx = with(density) { KNOB_SIZE_DP.dp.roundToPx() } + val labelTopPadPx = with(density) { LABEL_TOP_PADDING_DP.dp.roundToPx() } - // Calculate step positions (evenly spaced) - val stepPositions = remember(steps, sliderWidth) { - if (sliderWidth == 0) { + val compositionStepPositions = remember(steps, layoutWidthPx) { + val sliderWidth = layoutWidthPx.toFloat() + if (sliderWidth <= 0f) { emptyList() } else { - steps.indices.map { index -> - val numSteps = (steps.size - 1).coerceAtLeast(1) - (index.toFloat() / numSteps) * sliderWidth - } + val numSteps = (steps.size - 1).coerceAtLeast(1) + steps.indices.map { index -> (index.toFloat() / numSteps) * sliderWidth } } } + val valueIndex = steps.indexOf(value).takeIf { it >= 0 } ?: 0 + val settledX = compositionStepPositions.getOrElse(valueIndex) { 0f } + val settledXState = rememberUpdatedState(settledX) - // Initialize knob position when value changes - LaunchedEffect(value, stepPositions) { - if (stepPositions.isNotEmpty()) { - val valueIndex = steps.indexOf(value) - if (valueIndex >= 0) { - knobPosition.snapTo(stepPositions[valueIndex]) - } + LaunchedEffect(settledX, isDragging) { + if (!isDragging) { + knobPosition.snapTo(settledX) } } - // Find closest step position - fun findClosestStep(currentPosition: Float): Pair { - if (stepPositions.isEmpty()) return 0f to 0 - - var closestPosition = stepPositions[0] - var closestIndex = 0 - var minDistance = abs(currentPosition - stepPositions[0]) - - stepPositions.forEachIndexed { index, position -> - val distance = abs(currentPosition - position) - if (distance < minDistance) { - minDistance = distance - closestPosition = position - closestIndex = index - } - } - - return closestPosition to closestIndex - } - - Box( + SubcomposeLayout( modifier = modifier .fillMaxWidth() - .onGloballyPositioned { coordinates -> - sliderWidth = coordinates.size.width - } - ) { - // Track and step markers - Canvas( - modifier = Modifier - .fillMaxWidth() - .height(KNOB_SIZE_DP.dp) - .pointerInput(Unit) { - detectTapGestures { offset -> - val (closestStep, closestIndex) = findClosestStep(offset.x) - coroutineScope.launch { - knobPosition.animateTo( - targetValue = closestStep, - animationSpec = SpringSpec(dampingRatio = 0.8f, stiffness = 400f), - ) - } - onValueChange(steps[closestIndex]) - } - } - ) { - val trackY = center.y - val trackHeight = density.run { TRACK_HEIGHT_DP.dp.toPx() } - val cornerRadius = density.run { 3.dp.toPx() } - - // Draw inactive track - drawRoundRect( - color = Colors.Green32, - topLeft = Offset(0f, trackY - trackHeight / 2), - size = Size(size.width, trackHeight), - cornerRadius = CornerRadius(cornerRadius), + .onSizeChanged { layoutWidthPx = it.width } + .stepSliderSemantics( + valueIndex = valueIndex, + stepCount = steps.size, + stateDescription = formatLabel(value), + onIndexChange = { onValueChange(steps[it]) }, ) + ) { constraints -> + val width = constraints.maxWidth + val sliderWidth = width.toFloat() + val stepPositions = if (sliderWidth <= 0f) { + emptyList() + } else { + val numSteps = (steps.size - 1).coerceAtLeast(1) + steps.indices.map { index -> (index.toFloat() / numSteps) * sliderWidth } + } + val knobX = if (isDragging) knobPosition.value else stepPositions.getOrElse(valueIndex) { 0f } - // Draw active track - val activeWidth = knobPosition.value - if (activeWidth > 0) { - drawRoundRect( - color = Colors.Green, - topLeft = Offset(0f, trackY - trackHeight / 2), - size = Size(activeWidth, trackHeight), - cornerRadius = CornerRadius(cornerRadius), - ) - } + fun findClosestStep(currentPosition: Float): Pair { + if (stepPositions.isEmpty()) return 0f to 0 - // Draw step markers - val markerWidth = density.run { STEP_MARKER_WIDTH_DP.dp.toPx() } - val markerHeight = density.run { STEP_MARKER_HEIGHT_DP.dp.toPx() } - val markerRadius = density.run { 2.5.dp.toPx() } + var closestPosition = stepPositions[0] + var closestIndex = 0 + var minDistance = abs(currentPosition - stepPositions[0]) - stepPositions.forEach { position -> - drawRoundRect( - color = Colors.White, - topLeft = Offset(position - markerWidth / 2, trackY - markerHeight / 2), - size = Size(markerWidth, markerHeight), - cornerRadius = CornerRadius(markerRadius), - ) + stepPositions.forEachIndexed { index, position -> + val distance = abs(currentPosition - position) + if (distance < minDistance) { + minDistance = distance + closestPosition = position + closestIndex = index + } } + + return closestPosition to closestIndex } - // Knob - Box( - modifier = Modifier - .offset { - IntOffset( - x = (knobPosition.value - with(density) { KNOB_SIZE_DP.dp.toPx() / 2 }).roundToInt(), - y = 0, - ) - } - .size(KNOB_SIZE_DP.dp) - .pointerInput(Unit) { - detectDragGestures( - onDragStart = { _ -> - // No action needed on drag start - }, - onDragEnd = { - val (closestStep, closestIndex) = findClosestStep(knobPosition.value) - coroutineScope.launch { - knobPosition.animateTo( - targetValue = closestStep, - animationSpec = SpringSpec(dampingRatio = 0.8f, stiffness = 400f), - ) + val trackPlaceable = subcompose(StepSliderSlot.Track) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(KNOB_SIZE_DP.dp) + ) { + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(KNOB_SIZE_DP.dp) + .pointerInput(stepPositions, steps, sliderWidth) { + detectTapGestures { offset -> + val (closestStep, closestIndex) = findClosestStep(offset.x) + coroutineScope.launch { + knobPosition.snapTo(settledXState.value) + isDragging = true + knobPosition.animateTo( + targetValue = closestStep, + animationSpec = SpringSpec(dampingRatio = 0.8f, stiffness = 400f), + ) + isDragging = false + } + onValueChange(steps[closestIndex]) } - onValueChange(steps[closestIndex]) - }, - ) { _, dragAmount -> - coroutineScope.launch { - val newPosition = (knobPosition.value + dragAmount.x) - .coerceIn(0f, sliderWidth.toFloat()) - knobPosition.snapTo(newPosition) } + ) { + val trackY = center.y + val trackHeight = density.run { TRACK_HEIGHT_DP.dp.toPx() } + val cornerRadius = density.run { 3.dp.toPx() } + + drawRoundRect( + color = Colors.Green32, + topLeft = Offset(0f, trackY - trackHeight / 2), + size = Size(size.width, trackHeight), + cornerRadius = CornerRadius(cornerRadius), + ) + + if (knobX > 0f) { + drawRoundRect( + color = Colors.Green, + topLeft = Offset(0f, trackY - trackHeight / 2), + size = Size(knobX, trackHeight), + cornerRadius = CornerRadius(cornerRadius), + ) + } + + val markerWidth = density.run { STEP_MARKER_WIDTH_DP.dp.toPx() } + val markerHeight = density.run { STEP_MARKER_HEIGHT_DP.dp.toPx() } + val markerRadius = density.run { 2.5.dp.toPx() } + + stepPositions.forEach { position -> + drawRoundRect( + color = Colors.White, + topLeft = Offset(position - markerWidth / 2, trackY - markerHeight / 2), + size = Size(markerWidth, markerHeight), + cornerRadius = CornerRadius(markerRadius), + ) } } - ) { - // Outer green circle - Box( - modifier = Modifier - .size(KNOB_SIZE_DP.dp) - .clip(CircleShape) - .background(Colors.Green) - ) { - // Inner white circle + Box( modifier = Modifier - .size(16.dp) - .clip(CircleShape) - .background(Colors.White) - .align(Alignment.Center) - ) + .offset { + IntOffset( + x = (knobX - with(density) { KNOB_SIZE_DP.dp.toPx() / 2 }).roundToInt(), + y = 0, + ) + } + .size(KNOB_SIZE_DP.dp) + .pointerInput(stepPositions, steps, sliderWidth) { + detectHorizontalDragGestures( + onDragStart = { + coroutineScope.launch { + knobPosition.snapTo(settledXState.value) + isDragging = true + } + }, + onDragEnd = { + val (closestStep, closestIndex) = findClosestStep(knobPosition.value) + coroutineScope.launch { + knobPosition.animateTo( + targetValue = closestStep, + animationSpec = SpringSpec(dampingRatio = 0.8f, stiffness = 400f), + ) + isDragging = false + } + onValueChange(steps[closestIndex]) + }, + onDragCancel = { + coroutineScope.launch { + knobPosition.snapTo(settledXState.value) + isDragging = false + } + }, + ) { _, dragAmount -> + coroutineScope.launch { + val newPosition = (knobPosition.value + dragAmount) + .coerceIn(0f, sliderWidth) + knobPosition.snapTo(newPosition) + } + } + } + ) { + Box( + modifier = Modifier + .size(KNOB_SIZE_DP.dp) + .clip(CircleShape) + .background(Colors.Green) + ) { + Box( + modifier = Modifier + .size(16.dp) + .clip(CircleShape) + .background(Colors.White) + .align(Alignment.Center) + ) + } + } } + }.first().measure(Constraints.fixed(width, knobHeightPx)) + + val labelsPlaceable = subcompose(StepSliderSlot.Labels) { + StepSliderLabels( + steps = steps, + formatLabel = formatLabel, + ) + }.first().measure(Constraints.fixedWidth(width)) + + val height = trackPlaceable.height + labelTopPadPx + labelsPlaceable.height + layout(width, height) { + trackPlaceable.placeRelative(0, 0) + labelsPlaceable.placeRelative(0, trackPlaceable.height + labelTopPadPx) } + } +} - // Step labels - steps.forEachIndexed { index, step -> - if (stepPositions.isNotEmpty() && index < stepPositions.size) { +private enum class StepSliderSlot { Track, Labels } + +private fun Modifier.stepSliderSemantics( + valueIndex: Int, + stepCount: Int, + stateDescription: String, + onIndexChange: (Int) -> Unit, +): Modifier = semantics { + this.stateDescription = stateDescription + val lastIndex = (stepCount - 1).coerceAtLeast(0) + progressBarRangeInfo = ProgressBarRangeInfo( + current = valueIndex.toFloat(), + range = 0f..lastIndex.toFloat(), + steps = (stepCount - 2).coerceAtLeast(0), + ) + setProgress { target -> + onIndexChange(target.roundToInt().coerceIn(0, lastIndex)) + true + } +} + +@Composable +private fun StepSliderLabels( + steps: ImmutableList, + formatLabel: (Int) -> String, + modifier: Modifier = Modifier, +) { + Layout( + modifier = modifier, + content = { + steps.forEach { step -> Caption13Up( - text = "$$step", + text = formatLabel(step), color = Colors.White64, textAlign = TextAlign.Center, - modifier = Modifier - .width(KNOB_SIZE_DP.dp) - .offset { - IntOffset( - x = (stepPositions[index] - with(density) { KNOB_SIZE_DP.dp.toPx() / 2 }).roundToInt(), - y = with(density) { (KNOB_SIZE_DP.dp + 4.dp).toPx() }.roundToInt(), - ) - } + modifier = Modifier.width(KNOB_SIZE_DP.dp) ) } + }, + ) { measurables, constraints -> + val placeables = measurables.map { measurable -> + measurable.measure(Constraints()) + } + val height = placeables.maxOfOrNull { it.height } ?: 0 + val width = constraints.maxWidth + val numSteps = (placeables.size - 1).coerceAtLeast(1) + + layout(width, height) { + placeables.forEachIndexed { index, placeable -> + val centerX = (index.toFloat() / numSteps) * width + val x = (centerX - placeable.width / 2f).roundToInt() + .coerceIn(0, (width - placeable.width).coerceAtLeast(0)) + placeable.placeRelative(x, 0) + } } } } /** - * Continuous slider over a [min]..[max] range, styled to match [StepSlider] (same track and + * Continuous slider over a [min]..[max] range, styled to match [Slider] (same track and * knob) but without discrete steps. Used to pick a transfer amount within its allowed limits. */ @Composable @@ -266,9 +356,9 @@ fun AmountSlider( fun fractionFor(v: Long): Float = ((v - min).toFloat() / span).coerceIn(0f, 1f) - fun valueFor(positionPx: Float): Long { + fun valueFor(logicalPositionPx: Float): Long { if (sliderWidth == 0) return min - val fraction = (positionPx / sliderWidth).coerceIn(0f, 1f) + val fraction = (logicalPositionPx / sliderWidth).coerceIn(0f, 1f) return (min + (fraction * span).roundToInt()).coerceIn(min, max) } @@ -279,6 +369,8 @@ fun AmountSlider( } } + val widthPx = sliderWidth.toFloat() + Box( modifier = modifier .fillMaxWidth() @@ -294,7 +386,7 @@ fun AmountSlider( .pointerInput(sliderWidth, min, max) { detectTapGestures { offset -> val v = valueFor(offset.x) - coroutineScope.launch { knobPosition.snapTo(fractionFor(v) * sliderWidth) } + coroutineScope.launch { knobPosition.snapTo(fractionFor(v) * widthPx) } onValueChange(v) } } @@ -311,12 +403,11 @@ fun AmountSlider( cornerRadius = CornerRadius(cornerRadius), ) // Active track - val activeWidth = knobPosition.value - if (activeWidth > 0) { + if (knobPosition.value > 0) { drawRoundRect( color = Colors.Green, topLeft = Offset(0f, trackY - trackHeight / 2), - size = Size(activeWidth, trackHeight), + size = Size(knobPosition.value, trackHeight), cornerRadius = CornerRadius(cornerRadius), ) } @@ -333,10 +424,10 @@ fun AmountSlider( } .size(KNOB_SIZE_DP.dp) .pointerInput(sliderWidth, min, max) { - detectDragGestures { _, dragAmount -> + detectHorizontalDragGestures { _, dragAmount -> coroutineScope.launch { - val newPosition = (knobPosition.value + dragAmount.x) - .coerceIn(0f, sliderWidth.toFloat()) + val newPosition = (knobPosition.value + dragAmount) + .coerceIn(0f, widthPx) knobPosition.snapTo(newPosition) onValueChange(valueFor(newPosition)) } @@ -367,7 +458,7 @@ private fun Preview() { AppThemeSurface { var value by remember { mutableIntStateOf(10) } Column(modifier = Modifier.padding(32.dp)) { - StepSlider( + Slider( value = value, steps = persistentListOf(1, 5, 10, 20, 50), onValueChange = { value = it }, @@ -378,7 +469,7 @@ private fun Preview() { @Preview @Composable -private fun AmountSliderPreview() { +private fun PreviewUnitStops() { AppThemeSurface { var value by remember { mutableLongStateOf(72_000L) } Column(modifier = Modifier.padding(32.dp)) { @@ -394,13 +485,22 @@ private fun AmountSliderPreview() { @Preview @Composable -private fun Preview2() { +private fun PreviewVerticalStack() { AppThemeSurface { + var dollars by remember { mutableIntStateOf(1) } + var times by remember { mutableIntStateOf(5) } Column(modifier = Modifier.padding(32.dp)) { - StepSlider( - value = 5, - steps = persistentListOf(1, 2, 5, 10), - onValueChange = {}, + Slider( + value = dollars, + steps = persistentListOf(1, 5, 10, 20, 50), + onValueChange = { dollars = it }, + ) + VerticalSpacer(32.dp) + Slider( + value = times, + steps = persistentListOf(1, 3, 5, 10, 50), + onValueChange = { times = it }, + formatLabel = { "$it×" }, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt index 24b631d3ec..012164adc2 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt @@ -45,7 +45,7 @@ import to.bitkit.ui.theme.Colors fun SendPendingScreen( paymentHash: String, amount: Long, - onPaymentSuccess: (String) -> Unit, + onPaymentSuccess: (String, Long) -> Unit, onPaymentError: (PendingPaymentResolution.Failure) -> Unit, onClose: () -> Unit, onViewDetails: (String) -> Unit, @@ -58,7 +58,10 @@ fun SendPendingScreen( uiState.resolution?.let { resolution -> LaunchedEffect(resolution) { when (resolution) { - is PendingPaymentResolution.Success -> onPaymentSuccess(resolution.paymentHash) + is PendingPaymentResolution.Success -> onPaymentSuccess( + resolution.paymentHash, + resolution.amountWithFeeSats ?: amount, + ) is PendingPaymentResolution.Failure -> onPaymentError(resolution) } viewModel.onResolutionHandled() diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt index 98643c683d..301d622f01 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt @@ -37,6 +37,9 @@ class SendPendingViewModel @Inject constructor( isInitialized = true pendingPaymentRepo.setActiveHash(paymentHash) _uiState.update { it.copy(amount = amount) } + pendingPaymentRepo.consumeResolution(paymentHash)?.let { resolution -> + _uiState.update { it.copy(resolution = resolution) } + } findActivity(paymentHash) observeResolution(paymentHash) } @@ -65,6 +68,7 @@ class SendPendingViewModel @Inject constructor( pendingPaymentRepo.resolution .filter { it.paymentHash == paymentHash } .collect { resolution -> + pendingPaymentRepo.consumeResolution(paymentHash) Logger.info( "Received payment resolution '${resolution::class.simpleName}' for '$paymentHash'", context = TAG, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendQuickPayScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendQuickPayScreen.kt index 5f03bbe4c4..aad987ca4d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendQuickPayScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendQuickPayScreen.kt @@ -8,8 +8,10 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -19,6 +21,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.R import to.bitkit.models.NodeLifecycleState import to.bitkit.models.SendFailureDetails +import to.bitkit.repositories.QuickPaySession import to.bitkit.ui.components.BalanceHeaderView import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.components.Display @@ -39,15 +42,22 @@ fun SendQuickPayScreen( quickPayData: QuickPayData, onPaymentComplete: (String, Long) -> Unit, onPaymentPending: (String, Long, String) -> Unit, + onFallBackToConfirm: () -> Unit, onShowError: (SendFailureDetails) -> Unit, viewModel: QuickPayViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val lightningState by viewModel.lightningState.collectAsStateWithLifecycle() + val session = remember { QuickPaySession() } + + DisposableEffect(session) { + viewModel.attach(session) + onDispose { viewModel.detach(session) } + } LaunchedEffect(quickPayData, lightningState.nodeLifecycleState) { if (lightningState.nodeLifecycleState is NodeLifecycleState.Running) { - viewModel.pay(quickPayData) + viewModel.pay(session, quickPayData) } } @@ -59,6 +69,7 @@ fun SendQuickPayScreen( is QuickPayResult.Pending -> { onPaymentPending(result.paymentHash, result.amount, result.paymentRequest) } + is QuickPayResult.FallBackToConfirm -> onFallBackToConfirm() is QuickPayResult.Error -> onShowError(result.failure) null -> Unit // continue showing loading state } diff --git a/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt index 145bea29e1..ea30a78d99 100644 --- a/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt @@ -2,10 +2,11 @@ package to.bitkit.ui.settings.quickPay import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -22,7 +23,8 @@ import to.bitkit.R import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyS import to.bitkit.ui.components.Caption13Up -import to.bitkit.ui.components.StepSlider +import to.bitkit.ui.components.Slider +import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.components.settings.SettingsSwitchRow import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon @@ -38,12 +40,15 @@ fun QuickPaySettingsScreen( ) { val isQuickPayEnabled by settingsViewModel.isQuickpayEnabled.collectAsStateWithLifecycle() val quickPayAmount by settingsViewModel.quickPayAmount.collectAsStateWithLifecycle() + val quickPayDailyLimitMultiplier by settingsViewModel.quickPayDailyLimitMultiplier.collectAsStateWithLifecycle() QuickPaySettingsScreenContent( isQuickPayEnabled = isQuickPayEnabled, quickPayAmount = quickPayAmount, + quickPayDailyLimitMultiplier = quickPayDailyLimitMultiplier, onToggleQuickPay = settingsViewModel::setIsQuickPayEnabled, onQuickPayAmountChange = settingsViewModel::setQuickPayAmount, + onQuickPayDailyLimitMultiplierChange = settingsViewModel::setQuickPayDailyLimitMultiplier, onBack = onBack, ) } @@ -52,11 +57,16 @@ fun QuickPaySettingsScreen( fun QuickPaySettingsScreenContent( isQuickPayEnabled: Boolean, quickPayAmount: Int, + quickPayDailyLimitMultiplier: Int, onToggleQuickPay: (Boolean) -> Unit = {}, onQuickPayAmountChange: (Int) -> Unit = {}, + onQuickPayDailyLimitMultiplierChange: (Int) -> Unit = {}, onBack: () -> Unit = {}, ) { val sliderSteps = remember { persistentListOf(1, 5, 10, 20, 50) } + val dailyLimitSteps = remember { persistentListOf(1, 3, 5, 10, 50) } + val dailyLimitUsd = quickPayAmount * quickPayDailyLimitMultiplier + val multiplierFormat = stringResource(R.string.settings__quickpay__settings__multiplier_format) ScreenColumn { AppTopBar( @@ -66,9 +76,11 @@ fun QuickPaySettingsScreenContent( ) Column( - modifier = Modifier.padding(horizontal = 16.dp) + modifier = Modifier + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()) ) { - Spacer(modifier = Modifier.height(16.dp)) + VerticalSpacer(16.dp) SettingsSwitchRow( title = stringResource(R.string.settings__quickpay__settings__toggle), @@ -77,7 +89,7 @@ fun QuickPaySettingsScreenContent( modifier = Modifier.testTag("QuickpayToggle") ) - Spacer(modifier = Modifier.height(16.dp)) + VerticalSpacer(16.dp) BodyM( text = stringResource(R.string.settings__quickpay__settings__text) @@ -85,23 +97,49 @@ fun QuickPaySettingsScreenContent( color = Colors.White64, ) - Spacer(modifier = Modifier.height(32.dp)) + VerticalSpacer(32.dp) Caption13Up( text = stringResource(R.string.settings__quickpay__settings__label), color = Colors.White64, ) - Spacer(modifier = Modifier.height(16.dp)) + VerticalSpacer(16.dp) - StepSlider( + Slider( value = quickPayAmount, steps = sliderSteps, onValueChange = onQuickPayAmountChange, - modifier = Modifier.testTag("quickpay_amount_slider") + modifier = Modifier.testTag("QuickpayAmountSlider") ) - Spacer(modifier = Modifier.weight(1f)) + VerticalSpacer(32.dp) + + Caption13Up( + text = stringResource(R.string.settings__quickpay__settings__daily_label), + color = Colors.White64, + ) + + VerticalSpacer(16.dp) + + BodyM( + text = stringResource(R.string.settings__quickpay__settings__daily_text) + .replace("{limit}", dailyLimitUsd.toString()) + .replace("{multiplier}", quickPayDailyLimitMultiplier.toString()), + color = Colors.White64, + ) + + VerticalSpacer(16.dp) + + Slider( + value = quickPayDailyLimitMultiplier, + steps = dailyLimitSteps, + onValueChange = onQuickPayDailyLimitMultiplierChange, + formatLabel = { multiplierFormat.replace("{multiplier}", it.toString()) }, + modifier = Modifier.testTag("QuickpayDailyLimitSlider") + ) + + VerticalSpacer(32.dp) Image( painter = painterResource(R.drawable.fast_forward), contentDescription = null, @@ -109,14 +147,14 @@ fun QuickPaySettingsScreenContent( .fillMaxWidth() .height(256.dp) ) - Spacer(modifier = Modifier.weight(1f)) + VerticalSpacer(32.dp) BodyS( text = stringResource(R.string.settings__quickpay__settings__note), color = Colors.White64, ) - Spacer(modifier = Modifier.height(16.dp)) + VerticalSpacer(16.dp) } } } @@ -128,6 +166,7 @@ private fun Preview() { QuickPaySettingsScreenContent( isQuickPayEnabled = true, quickPayAmount = 5, + quickPayDailyLimitMultiplier = 5, ) } } diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index c5d3c95d05..15753d15a7 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -351,6 +351,12 @@ fun SendSheet( popUpTo(startDestination) { inclusive = true } } }, + onFallBackToConfirm = { + appViewModel.resetQuickPay() + navController.navigateTo(SendRoute.Confirm) { + popUpTo { inclusive = true } + } + }, onShowError = { failure -> appViewModel.clearActiveContactPaymentContext() navController.navigateTo( @@ -368,13 +374,13 @@ fun SendSheet( SendPendingScreen( paymentHash = route.paymentHash, amount = route.amount, - onPaymentSuccess = { paymentHash -> + onPaymentSuccess = { paymentHash, amountWithFee -> appViewModel.onSendSuccess( NewTransactionSheetDetails( type = NewTransactionSheetType.LIGHTNING, direction = NewTransactionSheetDirection.SENT, paymentHashOrTxId = paymentHash, - sats = route.amount, + sats = amountWithFee, ), ) }, diff --git a/app/src/main/java/to/bitkit/utils/Errors.kt b/app/src/main/java/to/bitkit/utils/Errors.kt index a3c915c77e..6cc3b0f973 100644 --- a/app/src/main/java/to/bitkit/utils/Errors.kt +++ b/app/src/main/java/to/bitkit/utils/Errors.kt @@ -60,7 +60,7 @@ class LdkError(private val inner: LdkException) : AppError("Unknown LDK error.") }?.let { "LDK Build error: $it" } } - class Node(exception: NodeException) : LdkException { + class Node(val exception: NodeException) : LdkException { override val compactType = exception::class.simpleName override val message = when (exception) { is NodeException.AlreadyRunning -> "The node is already running." @@ -125,6 +125,14 @@ class LdkError(private val inner: LdkException) : AppError("Unknown LDK error.") }?.let { "LDK Node error: $it" } } } + + fun nodeExceptionOrNull(): NodeException? = (inner as? LdkException.Node)?.exception +} + +fun Throwable.asNodeException(): NodeException? = when (this) { + is NodeException -> this + is LdkError -> nodeExceptionOrNull() + else -> cause?.asNodeException() } // endregion diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 4b71ee0f05..2497e09f0f 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -125,6 +125,7 @@ import to.bitkit.models.Toast import to.bitkit.models.TransactionSpeed import to.bitkit.models.TransferType import to.bitkit.models.TransportType +import to.bitkit.models.USD import to.bitkit.models.msatFloorOf import to.bitkit.models.safe import to.bitkit.models.sanitizedDeeplinkLogValue @@ -156,6 +157,7 @@ import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitPaymentResult import to.bitkit.repositories.PublicPaykitRepo +import to.bitkit.repositories.QuickPayRepo import to.bitkit.repositories.SamRockRepo import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo @@ -220,6 +222,7 @@ class AppViewModel @Inject constructor( private val notifyPaymentReceivedHandler: NotifyPaymentReceivedHandler, private val notifyChannelReadyHandler: NotifyChannelReadyHandler, private val cacheStore: CacheStore, + private val quickPayRepo: QuickPayRepo, private val transferRepo: TransferRepo, private val migrationService: MigrationService, private val coreService: CoreService, @@ -1156,12 +1159,19 @@ class AppViewModel @Inject constructor( } private suspend fun handlePaymentFailed(event: Event.PaymentFailed) { - event.paymentHash?.let { paymentHash -> + val outcome = quickPayRepo.signalCompletion( + paymentId = event.paymentId, + paymentHash = event.paymentHash, + success = false, + failureReason = event.reason, + ) + val paymentHash = event.paymentHash ?: outcome.invoicePaymentHash ?: event.paymentId + if (paymentHash != null) { activityRepo.handlePaymentEvent(paymentHash) if (pendingPaymentRepo.isPending(paymentHash)) { clearPendingContactPaymentContext(paymentHash) pendingPaymentRepo.resolve(PendingPaymentResolution.Failure(paymentHash, event.reason)) - if (_currentSheet.value !is Sheet.Send || !pendingPaymentRepo.isActive(paymentHash)) { + if (shouldNotifyPendingResolution(paymentHash)) { notifyPendingPaymentFailed() } return @@ -1171,7 +1181,13 @@ class AppViewModel @Inject constructor( notifyPaymentFailed(event.reason) } + private fun shouldNotifyPendingResolution(paymentHash: String): Boolean { + if (_quickPayData.value != null) return false + return _currentSheet.value !is Sheet.Send || !pendingPaymentRepo.isActive(paymentHash) + } + private fun closeActiveSendForFailedPayment(paymentHash: String, reason: PaymentFailureReason?): Boolean { + if (_quickPayData.value != null) return false val activePaymentHash = _sendUiState.value.decodedInvoice?.paymentHash?.toHex() if (_currentSheet.value !is Sheet.Send || activePaymentHash != paymentHash) return false @@ -1232,18 +1248,48 @@ class AppViewModel @Inject constructor( } private suspend fun handlePaymentSuccessful(event: Event.PaymentSuccessful) { - event.paymentHash.let { paymentHash -> - activityRepo.handlePaymentEvent(paymentHash) - if (pendingPaymentRepo.isPending(paymentHash)) { - syncContactForActivity(paymentHash) - pendingPaymentRepo.resolve(PendingPaymentResolution.Success(paymentHash)) - if (_currentSheet.value !is Sheet.Send || !pendingPaymentRepo.isActive(paymentHash)) { - notifyPendingPaymentSucceeded() - } - return - } + val paymentHash = event.paymentHash + activityRepo.handlePaymentEvent(paymentHash) + val isQuickPay = quickPayRepo.signalCompletion( + paymentId = event.paymentId, + paymentHash = paymentHash, + success = true, + feePaidMsat = event.feePaidMsat, + ).wasQuickPay + if (!pendingPaymentRepo.isPending(paymentHash)) { + notifyPaymentSentOnLightning(event) + return + } + syncContactForActivity(paymentHash) + val amountWithFeeSats = quickPaySettledAmountSats(paymentHash, isQuickPay, event.feePaidMsat) + pendingPaymentRepo.resolve( + PendingPaymentResolution.Success( + paymentHash = paymentHash, + amountWithFeeSats = amountWithFeeSats, + ), + ) + if (shouldNotifyPendingResolution(paymentHash)) { + notifyPendingPaymentSucceeded() + } + } + + private suspend fun quickPaySettledAmountSats( + paymentHash: String, + isQuickPay: Boolean, + feePaidMsat: ULong?, + ): Long? { + if (!isQuickPay) return null + val principal = ( + activityRepo.findActivityByPaymentId( + paymentHashOrTxId = paymentHash, + type = ActivityFilter.LIGHTNING, + txType = PaymentType.SENT, + retry = true, + ).getOrNull() as? Activity.Lightning + )?.v1?.value + return principal?.let { + (it.safe() + msatFloorOf(feePaidMsat ?: 0u).safe()).toLong() } - notifyPaymentSentOnLightning(event) } // region Notifications @@ -2644,40 +2690,57 @@ class AppViewModel @Inject constructor( lnurlPay: LnurlPayData? = null, invoice: LightningInvoice? = null, ): Boolean { - if (hasActiveContactPaymentContext()) return false + val invoiceHash = invoice?.paymentHash?.toHex()?.takeIf { it.isNotBlank() } + val open = invoiceHash != null && quickPayRepo.hasOpen(invoiceHash) + if (!open && !canApplyQuickPay(amountSats)) return false - val settings = settingsStore.data.first() - if (!settings.isQuickPayEnabled || amountSats == 0uL) return false + Logger.info("Using QuickPay for '$amountSats' sats", context = TAG) - val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD").getOrNull() - ?: return false + val quickPayData: QuickPayData = when { + lnurlPay != null -> { + QuickPayData.LnurlPay( + sats = amountSats, + data = lnurlPay, + ) + } - if (amountSats <= quickPayAmountSats) { - Logger.info("Using QuickPay: $amountSats sats <= $quickPayAmountSats sats threshold", context = TAG) + else -> { + val decodedInvoice = requireNotNull(invoice) + QuickPayData.Bolt11(sats = amountSats, bolt11 = decodedInvoice.bolt11) + } + } - val quickPayData: QuickPayData = when { - lnurlPay != null -> { - QuickPayData.LnurlPay( - sats = amountSats, - data = lnurlPay, - ) - } + _quickPayData.update { quickPayData } - else -> { - val decodedInvoice = requireNotNull(invoice) - QuickPayData.Bolt11(sats = amountSats, bolt11 = decodedInvoice.bolt11) - } + if (lnurlPay != null) { + _sendUiState.update { + it.copy( + amount = amountSats, + payMethod = SendMethod.LIGHTNING, + lnurl = LnurlParams.LnurlPay(lnurlPay), + ) } + } else if (invoice != null) { + _sendUiState.update { + it.copy( + amount = amountSats, + addressInput = invoice.bolt11, + isAddressInputValid = true, + decodedInvoice = invoice, + payMethod = SendMethod.LIGHTNING, + ) + } + } - _quickPayData.update { quickPayData } - - Logger.debug("QuickPayData: $quickPayData", context = TAG) + Logger.debug("QuickPayData: $quickPayData", context = TAG) - navigateToSendRoute(fromMainScanner, SendRoute.QuickPay, SendEffect.NavigateToQuickPay) - return true - } + navigateToSendRoute(fromMainScanner, SendRoute.QuickPay, SendEffect.NavigateToQuickPay) + return true + } - return false + private suspend fun canApplyQuickPay(amountSats: ULong): Boolean { + if (hasActiveContactPaymentContext()) return false + return quickPayRepo.canApply(amountSats).getOrDefault(false) } private fun resetAmountInput() { @@ -2721,7 +2784,7 @@ class AppViewModel @Inject constructor( return } - val amountInUsd = currencyRepo.convertSatsToFiat(amountSats.toLong(), "USD").getOrNull() ?: return + val amountInUsd = currencyRepo.convertSatsToFiat(amountSats.toLong(), USD).getOrNull() ?: return if ( amountInUsd.value > BigDecimal(SEND_AMOUNT_WARNING_THRESHOLD) && settings.enableSendAmountWarning && @@ -2754,7 +2817,7 @@ class AppViewModel @Inject constructor( return } - val feeInUsd = currencyRepo.convertSatsToFiat(totalFee.toLong(), "USD").getOrNull() ?: return + val feeInUsd = currencyRepo.convertSatsToFiat(totalFee.toLong(), USD).getOrNull() ?: return if ( feeInUsd.value > BigDecimal(TEN_USD) && SanityWarning.FEE_OVER_10_USD !in _sendUiState.value.confirmedWarnings @@ -3434,6 +3497,10 @@ class AppViewModel @Inject constructor( fun hideSheet() = hideSheet(shouldFlushDeferredScan = true) private fun hideSheet(shouldFlushDeferredScan: Boolean) { + if (_currentSheet.value is Sheet.Send) { + resetQuickPay() + quickPayRepo.detachAll() + } scanResultHandler = null receiveSheetContext = null sheetTransitionJob?.cancel() diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index 54b2f92e61..c15d88e132 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -5,142 +5,93 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import org.lightningdevkit.ldknode.Event -import org.lightningdevkit.ldknode.PaymentFailureReason -import org.lightningdevkit.ldknode.PaymentId -import to.bitkit.ext.WatchResult -import to.bitkit.ext.callbackAmountMsats -import to.bitkit.ext.supportPaymentRequest +import to.bitkit.R +import to.bitkit.ext.toCompactFailureType import to.bitkit.ext.toSendFailureDetails -import to.bitkit.ext.watchUntil import to.bitkit.models.SendFailureDetails import to.bitkit.repositories.LightningRepo -import to.bitkit.repositories.PaymentPendingException -import to.bitkit.repositories.PendingPaymentRepo -import to.bitkit.utils.AppError -import to.bitkit.utils.Logger +import to.bitkit.repositories.QuickPayConversionError +import to.bitkit.repositories.QuickPayPayRequest +import to.bitkit.repositories.QuickPayPaymentFailedError +import to.bitkit.repositories.QuickPayRepo +import to.bitkit.repositories.QuickPaySession +import to.bitkit.repositories.QuickPaySessionEvent import javax.inject.Inject @HiltViewModel class QuickPayViewModel @Inject constructor( @ApplicationContext private val context: Context, private val lightningRepo: LightningRepo, - private val pendingPaymentRepo: PendingPaymentRepo, + private val quickPayRepo: QuickPayRepo, ) : ViewModel() { - - companion object { - private const val TAG = "QuickPayViewModel" - } - private val _uiState = MutableStateFlow(QuickPayUiState()) val uiState = _uiState.asStateFlow() val lightningState = lightningRepo.lightningState - - fun pay(data: QuickPayData) { - viewModelScope.launch { - val invoice = resolveQuickPayInvoice(data) ?: return@launch - - sendLightning(invoice.bolt11, invoice.amount) - .onSuccess { paymentHash -> - Logger.info("QuickPay lightning payment successful") - _uiState.update { - it.copy( - result = QuickPayResult.Success( - paymentHash = paymentHash, - amountWithFee = invoice.displaySats.toLong() // TODO GET FEE WHEN AVAILABLE - ) - ) - } - }.onFailure { error -> - if (error is PaymentPendingException) { - Logger.info("QuickPay lightning payment pending", context = TAG) - pendingPaymentRepo.track(error.paymentHash) - _uiState.update { - it.copy( - result = QuickPayResult.Pending( - paymentHash = error.paymentHash, - amount = invoice.displaySats.toLong(), - paymentRequest = invoice.paymentRequest, - ) - ) - } - return@onFailure - } - Logger.error("QuickPay lightning payment failed", error, context = TAG) - - handleQuickPayFailure(error, invoice) - } + private var session: QuickPaySession? = null + private var resultJob: Job? = null + + fun attach(session: QuickPaySession) { + this.session = session + resultJob?.cancel() + resultJob = viewModelScope.launch { + quickPayRepo.attach(session).collect { event -> + _uiState.update { it.copy(result = event.toUiResult()) } + } } } - private suspend fun resolveQuickPayInvoice(data: QuickPayData): QuickPayInvoice? { - return when (data) { - is QuickPayData.Bolt11 -> { - Logger.info("QuickPay: processing bolt11 invoice") - QuickPayInvoice(data.bolt11, null, data.sats, data.bolt11) - } - - is QuickPayData.LnurlPay -> { - Logger.info("QuickPay: fetching LNURL Pay invoice from callback") - lightningRepo.fetchLnurlInvoice( - data = data.data, - amountMsats = data.data.callbackAmountMsats(data.sats), - ).fold( - onSuccess = { QuickPayInvoice(it.bolt11, null, data.sats, data.data.supportPaymentRequest()) }, - onFailure = { - _uiState.update { state -> - state.copy( - result = QuickPayResult.Error( - it.toSendFailureDetails(context, data.data.supportPaymentRequest()) - ) - ) - } - null - }, - ) - } + fun detach(session: QuickPaySession) { + quickPayRepo.detach(session) + if (this.session?.id == session.id) { + this.session = null } } - private fun handleQuickPayFailure(error: Throwable, invoice: QuickPayInvoice) { - val failure = when (error) { - is QuickPayPaymentFailedError -> error.reason.toSendFailureDetails(context, error.paymentRequest) - else -> error.toSendFailureDetails(context, invoice.bolt11.ifBlank { invoice.fallbackPaymentRequest }) - } - _uiState.update { - it.copy(result = QuickPayResult.Error(failure)) - } + fun pay(session: QuickPaySession, data: QuickPayData) { + if (_uiState.value.result != null) return + quickPayRepo.pay(session, data.toPayRequest()) } - private suspend fun sendLightning( - bolt11: String, - amount: ULong? = null, - ): Result { - val hash = lightningRepo.payInvoice(bolt11 = bolt11, sats = amount) - .onFailure { exception -> - return Result.failure(exception) - } - .getOrDefault("") + override fun onCleared() { + session?.let { quickPayRepo.detach(it) } + super.onCleared() + } - // Wait until matching payment event is received (with timeout for hold invoices) - val result = lightningRepo.nodeEvents.watchUntil(LightningRepo.SEND_LN_TIMEOUT) { - when (it) { - is Event.PaymentSuccessful if it.paymentHash == hash -> WatchResult.Complete(Result.success(hash)) - is Event.PaymentFailed if it.paymentHash == hash -> WatchResult.Complete( - Result.failure( - QuickPayPaymentFailedError(reason = it.reason, paymentRequest = bolt11) - ) - ) + private fun QuickPaySessionEvent.toUiResult(): QuickPayResult = when (this) { + is QuickPaySessionEvent.Success -> QuickPayResult.Success( + paymentHash = paymentHash, + amountWithFee = amountWithFee, + ) + is QuickPaySessionEvent.Pending -> QuickPayResult.Pending( + paymentHash = paymentHash, + amount = amount, + paymentRequest = paymentRequest, + ) + QuickPaySessionEvent.FallBackToConfirm -> QuickPayResult.FallBackToConfirm + is QuickPaySessionEvent.Error -> QuickPayResult.Error(error.toUiFailure(paymentRequest)) + } - else -> WatchResult.Continue() - } + private fun Throwable.toUiFailure(paymentRequest: String?): SendFailureDetails { + return when (this) { + is QuickPayConversionError -> SendFailureDetails( + message = context.getString(R.string.wallet__send_quickpay__currency_conversion), + failureType = toCompactFailureType(), + resetRoutingCachesOnRetry = false, + ) + is QuickPayPaymentFailedError -> reason.toSendFailureDetails(context, paymentRequest ?: this.paymentRequest) + else -> toSendFailureDetails(context, paymentRequest) } - return result ?: Result.failure(PaymentPendingException(hash)) + } + + private fun QuickPayData.toPayRequest(): QuickPayPayRequest = when (this) { + is QuickPayData.Bolt11 -> QuickPayPayRequest.Bolt11(bolt11 = bolt11, amountSats = sats) + is QuickPayData.LnurlPay -> QuickPayPayRequest.LnurlPay(data = data, amountSats = sats) } } @@ -156,23 +107,11 @@ sealed class QuickPayResult { val paymentRequest: String, ) : QuickPayResult() + data object FallBackToConfirm : QuickPayResult() + data class Error(val failure: SendFailureDetails) : QuickPayResult() } data class QuickPayUiState( val result: QuickPayResult? = null, ) - -private data class QuickPayInvoice( - val bolt11: String, - val amount: ULong?, - val displaySats: ULong, - val fallbackPaymentRequest: String, -) { - val paymentRequest get() = bolt11.ifBlank { fallbackPaymentRequest } -} - -private class QuickPayPaymentFailedError( - val reason: PaymentFailureReason?, - val paymentRequest: String?, -) : AppError(reason?.name) diff --git a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt index 9410f5c5f2..e406efd3e6 100644 --- a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt @@ -348,6 +348,15 @@ class SettingsViewModel @Inject constructor( } } + val quickPayDailyLimitMultiplier = settingsStore.data.map { it.quickPayDailyLimitMultiplier } + .asStateFlow(initialValue = 5) + + fun setQuickPayDailyLimitMultiplier(value: Int) { + viewModelScope.launch { + settingsStore.update { it.copy(quickPayDailyLimitMultiplier = value) } + } + } + val enableSwipeToHideBalance = settingsStore.data.map { it.enableSwipeToHideBalance } .asStateFlow(initialValue = true) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 357583ed8f..57a288533a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -933,7 +933,10 @@ Bitkit QuickPay makes checking out faster by automatically paying QR codes when scanned. <accent>Frictionless</accent>\npayments QuickPay + Daily QuickPay limit + Auto-pay up to ${limit} per day without PIN ({multiplier}× your threshold). After that, payments open Confirm. Quickpay threshold + {multiplier}× * Bitkit QuickPay exclusively supports payments from your Spending Balance. If enabled, scanned invoices below ${amount} will be paid automatically without requiring your confirmation or PIN*. Enable QuickPay @@ -1249,6 +1252,7 @@ Reserve Balance This payment is taking a bit longer than expected. You can continue using Bitkit. Payment Pending + Currency conversion failed QuickPay Paying\n<accent>invoice...</accent> Confirm diff --git a/app/src/test/java/to/bitkit/repositories/PendingPaymentRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PendingPaymentRepoTest.kt index 301c83ce72..ee7007a765 100644 --- a/app/src/test/java/to/bitkit/repositories/PendingPaymentRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PendingPaymentRepoTest.kt @@ -7,6 +7,7 @@ import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertIs +import kotlin.test.assertNull import kotlin.test.assertTrue class PendingPaymentRepoTest : BaseUnitTest() { @@ -96,6 +97,44 @@ class PendingPaymentRepoTest : BaseUnitTest() { assertFalse(sut.isActive("hash1")) } + @Test + fun `late collector does not receive a buffered resolution`() = test { + sut.track("hash1") + sut.resolve(PendingPaymentResolution.Success("hash1")) + sut.resolution.test { + expectNoEvents() + } + assertIs(sut.consumeResolution("hash1")) + } + + @Test + fun `consumeResolution returns last resolve for that hash`() = test { + sut.resolve(PendingPaymentResolution.Success("hash1", amountWithFeeSats = 510L)) + + val taken = sut.consumeResolution("hash1") + + assertIs(taken) + assertEquals(510L, taken.amountWithFeeSats) + assertNull(sut.consumeResolution("hash1")) + } + + @Test + fun `track clears a cached resolution for that hash`() = test { + sut.resolve(PendingPaymentResolution.Failure("hash1")) + sut.track("hash1") + + assertNull(sut.consumeResolution("hash1")) + assertTrue(sut.isPending("hash1")) + } + + @Test + fun `consumeResolution ignores other hashes`() = test { + sut.resolve(PendingPaymentResolution.Failure("hash1")) + + assertNull(sut.consumeResolution("hash2")) + assertIs(sut.consumeResolution("hash1")) + } + @Test fun `resolve does not affect activeHash`() = test { sut.track("hash1") diff --git a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt new file mode 100644 index 0000000000..d229fd2ccd --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt @@ -0,0 +1,803 @@ +package to.bitkit.repositories + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import app.cash.turbine.test +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.lightningdevkit.ldknode.NodeException +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import to.bitkit.data.AppCacheData +import to.bitkit.data.CacheStore +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.di.json +import to.bitkit.models.ConvertedAmount +import to.bitkit.models.USD +import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.LdkError +import java.math.BigDecimal +import java.util.Locale +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Instant + +@OptIn(ExperimentalCoroutinesApi::class) +@Config(application = Application::class, sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class QuickPayRepoTest : BaseUnitTest() { + companion object { + private const val TEST_BOLT11 = "lnbcrt1quickpay" + private const val TEST_HASH = "quickpay-invoice-hash" + private val IOS_LEDGER_JSON = """ + { + "version": 1, + "dayKey": "2026-08-15", + "spentCents": 250, + "records": [ + { + "id": "rec-ios", + "amountCents": 250, + "dayKey": "2026-08-15", + "invoicePaymentHash": "inv-ios", + "phase": "submitting" + } + ] + } + """.trimIndent() + private val ANDROID_LEDGER_JSON = """ + { + "version": 1, + "dayKey": "2026-08-15", + "spentCents": 500, + "records": [ + { + "id": "rec-android", + "amountCents": 250, + "dayKey": "2026-08-15", + "invoicePaymentHash": "inv-android", + "paymentId": "pid-android", + "phase": "submitted" + }, + { + "id": "rec-android-2", + "amountCents": 250, + "dayKey": "2026-08-15", + "invoicePaymentHash": "inv-android-2", + "paymentId": null, + "phase": "submitting" + } + ] + } + """.trimIndent() + } + private val context = ApplicationProvider.getApplicationContext() + private val cacheStore = CacheStore(context) + private val settingsStore: SettingsStore = mock() + private val currencyRepo: CurrencyRepo = mock() + private val lightningRepo: LightningRepo = mock() + private val pendingPaymentRepo: PendingPaymentRepo = mock() + private val clock = MutableClock(Instant.parse("2026-08-15T12:00:00Z")) + private val settingsData = MutableStateFlow( + SettingsData(isQuickPayEnabled = true, quickPayAmount = 5, quickPayDailyLimitMultiplier = 5), + ) + private val lightningState = MutableStateFlow(LightningState()) + private var paymentRows: List? = null + + private lateinit var sut: QuickPayRepo + + @Before + fun setUp() = runBlocking { + cacheStore.reset() + paymentRows = null + whenever(settingsStore.data).thenReturn(settingsData) + whenever(lightningRepo.lightningState).thenReturn(lightningState) + whenever { lightningRepo.listPaymentsOrNull() }.thenReturn(null) + whenever(currencyRepo.convertFiatToSats(5.0, USD)).thenAnswer { 1000uL } + whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())).thenAnswer { invocation -> + val sats = invocation.getArgument(0) + val usd = 5.0 * sats.toDouble() / 1000.0 + ConvertedAmount( + value = BigDecimal.valueOf(usd), + formatted = usd.toString(), + symbol = "$", + currency = "USD", + flag = "", + sats = sats, + locale = Locale.US, + ) + } + sut = repo() + } + + @After + fun tearDown() = runBlocking { cacheStore.reset() } + + @Test + fun `reserveBound on clock rollback keeps existing spend`() = test { + assertNotNull(sut.reserveBound("a", 500u).getOrThrow()) + clock.instant = Instant.parse("2026-08-14T12:00:00Z") + + assertNotNull(sut.reserveBound("b", 200u).getOrThrow()) + assertEquals(350L, spentCents()) + clock.instant = Instant.parse("2026-08-15T12:00:00Z") + assertEquals(350L, spentCents()) + } + + @Test + fun `reserveBound accumulates on the same day and resets on a new day`() = test { + assertNotNull(sut.reserveBound("a", 400u).getOrThrow()) + assertNotNull(sut.reserveBound("b", 300u).getOrThrow()) + assertEquals(350L, spentCents()) + + clock.instant = Instant.parse("2026-08-16T12:00:00Z") + assertNotNull(sut.reserveBound("c", 800u).getOrThrow()) + assertEquals(400L, spentCents()) + } + + @Test + fun `reserveBound reserves under the cap and rejects over it`() = test { + settingsData.value = settingsData.value.copy(quickPayDailyLimitMultiplier = 2) + assertNotNull(sut.reserveBound("a", 1000u).getOrThrow()) + assertNotNull(sut.reserveBound("b", 1000u).getOrThrow()) + assertNull(sut.reserveBound("c", 1000u).getOrThrow()) + assertEquals(1000L, spentCents()) + } + + @Test + fun `reserveBound rejects a duplicate invoice hash`() = test { + assertNotNull(sut.reserveBound("abc", 1000u).getOrThrow()) + assertNull(sut.reserveBound("abc", 1000u).getOrThrow()) + } + + @Test + fun `signalCompletion failure rolls back a reservation`() = test { + assertNotNull(sut.reserveBound("abc", 1000u).getOrThrow()) + markSubmitted("abc", "pid") + + val outcome = sut.signalCompletion(paymentId = "pid", paymentHash = "abc", success = false) + + assertEquals(QuickPayCompletionKind.SETTLED_FAILURE, outcome.kind) + assertEquals(0L, spentCents()) + assertTrue(cacheStore.data.first().quickPayLedger!!.records.isEmpty()) + } + + @Test + fun `signalCompletion failure on a prior day does not decrement the new day`() = test { + assertNotNull(sut.reserveBound("old", 1000u).getOrThrow()) + markSubmitted("old", "old-pid") + clock.instant = Instant.parse("2026-08-16T12:00:00Z") + assertNotNull(sut.reserveBound("new", 800u).getOrThrow()) + + sut.signalCompletion(paymentId = "old-pid", paymentHash = "old", success = false) + + assertEquals(400L, spentCents()) + } + + @Test + fun `signalCompletion success keeps spend`() = test { + assertNotNull(sut.reserveBound("abc", 1000u).getOrThrow()) + + val outcome = sut.signalCompletion(paymentId = "pid", paymentHash = "abc", success = true) + + assertEquals(QuickPayCompletionKind.SETTLED_SUCCESS, outcome.kind) + assertEquals(500L, spentCents()) + assertTrue(cacheStore.data.first().quickPayLedger!!.records.isEmpty()) + } + + @Test + fun `signalCompletion is idempotent`() = test { + assertNotNull(sut.reserveBound("abc", 1000u).getOrThrow()) + sut.signalCompletion(paymentId = null, paymentHash = "abc", success = true) + + val second = sut.signalCompletion(paymentId = null, paymentHash = "abc", success = true) + + assertEquals(QuickPayCompletionOutcome.None, second) + assertEquals(500L, spentCents()) + } + + @Test + fun `dual aliases settle one record`() = test { + assertNotNull(sut.reserveBound("inv", 1000u).getOrThrow()) + sut.signalCompletion(paymentId = "pid", paymentHash = "other", success = true) + // paymentId was not stored yet; settle by invoice hash then alias + val first = sut.signalCompletion(paymentId = "pid", paymentHash = "inv", success = true) + val second = sut.signalCompletion(paymentId = "pid", paymentHash = "inv", success = false) + + assertEquals(QuickPayCompletionKind.SETTLED_SUCCESS, first.kind) + assertEquals(QuickPayCompletionOutcome.None, second) + } + + @Test + fun `unattributable failed event against submitting retains`() = test { + assertNotNull(sut.reserveBound("inv", 1000u).getOrThrow()) + + val outcome = sut.signalCompletion(paymentId = "stale-pid", paymentHash = "other", success = false) + + assertEquals(QuickPayCompletionOutcome.None, outcome) + assertEquals(500L, spentCents()) + assertEquals(1, cacheStore.data.first().quickPayLedger!!.records.size) + } + + @Test + fun `canApply is true under threshold and cap`() = test { + assertTrue(sut.canApply(500u).getOrThrow()) + } + + @Test + fun `canApply is false when daily cap would be exceeded`() = test { + settingsData.value = settingsData.value.copy(quickPayDailyLimitMultiplier = 1) + assertNotNull(sut.reserveBound("a", 1000u).getOrThrow()) + + assertFalse(sut.canApply(1000u).getOrThrow()) + } + + @Test + fun `canApply is false when disabled`() = test { + settingsData.value = settingsData.value.copy(isQuickPayEnabled = false) + + assertFalse(sut.canApply(500u).getOrThrow()) + } + + @Test + fun `zero cent conversion at a full cap is rejected`() = test { + stubZeroCentConversion(7L) + repeat(5) { assertNotNull(sut.reserveBound("h$it", 1000u).getOrThrow()) } + + assertFalse(sut.canApply(7u).getOrThrow()) + assertNull(sut.reserveBound("dust", 7u).getOrThrow()) + } + + @Test + fun `zero cent conversion on a fresh day reserves one cent`() = test { + stubZeroCentConversion(7L) + + val reserved = requireNotNull(sut.reserveBound("dust", 7u).getOrThrow()) + + assertEquals(1L, reserved.amountCents) + assertEquals(1L, spentCents()) + assertTrue(sut.canApply(7u).getOrThrow()) + } + + @Test + fun `reserveBound fails with conversion error when rates are unavailable`() = test { + whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())).thenAnswer { + throw QuickPayConversionError() + } + + val result = sut.reserveBound("abc", 500u) + + assertTrue(result.exceptionOrNull() is QuickPayConversionError) + } + + @Test + fun `fresh repo does not reserve the same recovered hash`() = test { + assertNotNull(sut.reserveBound("inv", 1000u).getOrThrow()) + val reloaded = repo() + + assertNull(reloaded.reserveBound("inv", 1000u).getOrThrow()) + verify(lightningRepo, never()).payInvoice(any(), anyOrNull(), any()) + } + + @Test + fun `ios ledger fixture decodes`() { + val ledger = json.decodeFromString(IOS_LEDGER_JSON) + assertEquals(1, ledger.version) + assertEquals("inv-ios", ledger.records.single().invoicePaymentHash) + assertEquals(QuickPayRecordPhase.SUBMITTING, ledger.records.single().phase) + assertNull(ledger.records.single().paymentId) + } + + @Test + fun `android ledger fixture decodes`() { + val ledger = json.decodeFromString(ANDROID_LEDGER_JSON) + assertEquals("pid-android", ledger.records.first().paymentId) + assertEquals(QuickPayRecordPhase.SUBMITTED, ledger.records.first().phase) + assertEquals(QuickPayRecordPhase.SUBMITTING, ledger.records.last().phase) + } + + @Test + fun `unsupported ledger version does not wipe unrelated cache`() = test { + cacheStore.update { + AppCacheData( + onchainAddress = "keep-me", + paidOrders = mapOf("order" to "tx"), + quickPayLedger = QuickPayLedger( + version = 99, + dayKey = "2026-08-15", + spentCents = 999L, + records = emptyList(), + ), + ) + } + + assertNull(sut.reserveBound("x", 1000u).getOrThrow()) + assertFalse(sut.canApply(500u).getOrThrow()) + val data = cacheStore.data.first() + assertEquals("keep-me", data.onchainAddress) + assertEquals(mapOf("order" to "tx"), data.paidOrders) + assertEquals(99, data.quickPayLedger?.version) + assertEquals(999L, data.quickPayLedger?.spentCents) + } + + @Test + fun `lookup throw on duplicate still emits pending`() = test { + val (bolt11, _) = testInvoice() + stubPayInvoiceFailure(NodeException.DuplicatePayment("dup")) + whenever { lightningRepo.listPaymentsOrNull() }.thenAnswer { error("uniffi") } + val session = QuickPaySession() + sut.attach(session).test { + sut.payNow(session, QuickPayPayRequest.Bolt11(bolt11 = bolt11, amountSats = 500u)) + assertIs(awaitItem()) + } + assertEquals(250L, spentCents()) + assertEquals(1, cacheStore.data.first().quickPayLedger!!.records.size) + } + + @Test + fun `live record survives day prune then settles`() = test { + val (bolt11, hash) = testInvoice() + val dispatched = CompletableDeferred() + whenever { lightningRepo.payInvoice(any(), anyOrNull(), any()) }.doSuspendableAnswer { invocation -> + val onBeforeSend = invocation.getArgument Boolean>(2) + if (!onBeforeSend()) return@doSuspendableAnswer Result.failure(PaymentAbortedBeforeSend()) + dispatched.complete(Unit) + Result.success("pid") + } + val session = QuickPaySession() + sut.attach(session) + backgroundScope.launch { + sut.payNow(session, QuickPayPayRequest.Bolt11(bolt11 = bolt11, amountSats = 500u)) + } + dispatched.await() + clock.instant = Instant.parse("2026-08-16T12:00:00Z") + paymentRows = listOf(succeededRow(hash)) + sut.reconcileAgainstLdk() + assertNotNull(sut.reserveBound("other", 200u).getOrThrow()) + val hashes = cacheStore.data.first().quickPayLedger!!.records.map { it.invoicePaymentHash } + assertTrue(hash in hashes) + val outcome = sut.signalCompletion(paymentId = "pid", paymentHash = hash, success = true) + assertEquals(QuickPayCompletionKind.SETTLED_SUCCESS, outcome.kind) + assertTrue(outcome.wasQuickPay) + assertFalse(hash in cacheStore.data.first().quickPayLedger!!.records.map { it.invoicePaymentHash }) + } + + @Test + fun `day-old unresolved records prune on a later reserve`() = test { + assertNotNull(sut.reserveBound("old", 1000u).getOrThrow()) + clock.instant = Instant.parse("2026-08-16T12:00:00Z") + assertNotNull(sut.reserveBound("new", 200u).getOrThrow()) + + val hashes = cacheStore.data.first().quickPayLedger!!.records.map { it.invoicePaymentHash } + assertFalse("old" in hashes) + assertTrue("new" in hashes) + } + + @Test + fun `classifies wrapped and unwrapped ldk errors`() { + assertEquals( + QuickPayDispatchClass.PRE_DISPATCH_REJECTION, + classifyDispatchError(NodeException.InvalidInvoice("bad")), + ) + assertEquals( + QuickPayDispatchClass.PRE_DISPATCH_REJECTION, + classifyDispatchError(LdkError(NodeException.InvalidInvoice("bad"))), + ) + assertEquals( + QuickPayDispatchClass.DUPLICATE_PAYMENT, + classifyDispatchError(NodeException.DuplicatePayment("dup")), + ) + assertEquals( + QuickPayDispatchClass.DUPLICATE_PAYMENT, + classifyDispatchError(LdkError(NodeException.DuplicatePayment("dup"))), + ) + assertEquals( + QuickPayDispatchClass.AMBIGUOUS, + classifyDispatchError(NodeException.PersistenceFailed("io")), + ) + assertEquals( + QuickPayDispatchClass.AMBIGUOUS, + classifyDispatchError(LdkError(NodeException.PaymentSendingFailed("send"))), + ) + } + + @Test + fun `invalid invoice pay does not dispatch`() = test { + val session = QuickPaySession() + sut.attach(session) + sut.pay(session, QuickPayPayRequest.Bolt11(bolt11 = "lnbcrt1test", amountSats = 500u)) + + verify(lightningRepo, never()).payInvoice(any(), anyOrNull(), any()) + } + + @Test + fun `duplicate payment with pending ldk does not refund`() = test { + val (bolt11, hash) = testInvoice() + stubPayInvoiceFailure(NodeException.DuplicatePayment("dup")) + paymentRows = listOf(pendingRow(hash)) + val session = QuickPaySession() + + sut.attach(session).test { + sut.payNow(session, QuickPayPayRequest.Bolt11(bolt11 = bolt11, amountSats = 500u)) + val pending = assertIs(awaitItem()) + assertEquals(hash, pending.paymentHash) + } + assertEquals(250L, spentCents()) + assertEquals(1, cacheStore.data.first().quickPayLedger!!.records.size) + } + + @Test + fun `duplicate payment with succeeded ldk refunds a fresh reserve and emits success`() = test { + val (bolt11, hash) = testInvoice() + stubPayInvoiceFailure(NodeException.DuplicatePayment("dup")) + paymentRows = listOf(succeededRow(hash)) + val session = QuickPaySession() + + sut.attach(session).test { + sut.payNow(session, QuickPayPayRequest.Bolt11(bolt11 = bolt11, amountSats = 500u)) + val success = assertIs(awaitItem()) + assertEquals(hash, success.paymentHash) + } + assertEquals(0L, spentCents()) + assertTrue(cacheStore.data.first().quickPayLedger!!.records.isEmpty()) + } + + @Test + fun `re-pay of a settled hash does not double-count`() = test { + val (bolt11, hash) = testInvoice() + assertNotNull(sut.reserveBound(hash, 500u).getOrThrow()) + sut.signalCompletion(paymentId = null, paymentHash = hash, success = true) + assertEquals(250L, spentCents()) + stubPayInvoiceFailure(NodeException.DuplicatePayment("dup")) + paymentRows = listOf(succeededRow(hash)) + val session = QuickPaySession() + + sut.attach(session).test { + sut.payNow(session, QuickPayPayRequest.Bolt11(bolt11 = bolt11, amountSats = 500u)) + assertIs(awaitItem()) + } + assertEquals(250L, spentCents()) + assertTrue(cacheStore.data.first().quickPayLedger!!.records.isEmpty()) + } + + @Test + fun `ambiguous pending emits pending and keeps spend`() = test { + val (bolt11, hash) = testInvoice() + stubPayInvoiceFailure(NodeException.PaymentSendingFailed("send")) + paymentRows = listOf(pendingRow(hash)) + val session = QuickPaySession() + + sut.attach(session).test { + sut.payNow(session, QuickPayPayRequest.Bolt11(bolt11 = bolt11, amountSats = 500u)) + val pending = assertIs(awaitItem()) + assertEquals(hash, pending.paymentHash) + } + assertEquals(250L, spentCents()) + verify(pendingPaymentRepo).track(hash) + } + + @Test + fun `rescan of a pending hash replays pending to a new session`() = test { + val (bolt11, hash) = testInvoice() + stubPayInvoiceFailure(NodeException.DuplicatePayment("dup")) + paymentRows = listOf(pendingRow(hash)) + val first = QuickPaySession() + val second = QuickPaySession() + + sut.attach(first).test { + sut.payNow(first, QuickPayPayRequest.Bolt11(bolt11 = bolt11, amountSats = 500u)) + assertIs(awaitItem()) + } + sut.attach(second).test { + sut.payNow(second, QuickPayPayRequest.Bolt11(bolt11 = bolt11, amountSats = 500u)) + assertIs(awaitItem()) + } + verify(lightningRepo, times(1)).payInvoice(any(), anyOrNull(), any()) + assertEquals(250L, spentCents()) + assertEquals(1, cacheStore.data.first().quickPayLedger!!.records.size) + } + + @Test + fun `rescan pending then success settles once`() = test { + val (bolt11, hash) = testInvoice() + stubPayInvoiceFailure(NodeException.DuplicatePayment("dup")) + paymentRows = listOf(pendingRow(hash)) + val first = QuickPaySession() + val second = QuickPaySession() + + sut.attach(first).test { + sut.payNow(first, QuickPayPayRequest.Bolt11(bolt11 = bolt11, amountSats = 500u)) + assertIs(awaitItem()) + sut.attach(second).test { + sut.payNow(second, QuickPayPayRequest.Bolt11(bolt11 = bolt11, amountSats = 500u)) + assertIs(awaitItem()) + val outcome = sut.signalCompletion(paymentId = "pid", paymentHash = hash, success = true) + assertTrue(outcome.wasQuickPay) + expectNoEvents() + } + expectNoEvents() + } + assertEquals(250L, spentCents()) + assertTrue(cacheStore.data.first().quickPayLedger!!.records.isEmpty()) + } + + @Test + fun `hasOpen is true for a live op or recovered row`() = test { + val (bolt11, hash) = testInvoice() + assertFalse(sut.hasOpen(hash)) + stubPayInvoiceFailure(NodeException.DuplicatePayment("dup")) + paymentRows = listOf(pendingRow(hash)) + val session = QuickPaySession() + sut.attach(session) + sut.payNow(session, QuickPayPayRequest.Bolt11(bolt11 = bolt11, amountSats = 500u)) + assertTrue(sut.hasOpen(hash)) + val recovered = "recovered-hash" + assertNotNull(sut.reserveBound(recovered, 500u).getOrThrow()) + assertTrue(repo().hasOpen(recovered)) + sut.signalCompletion(paymentId = null, paymentHash = hash, success = true) + assertFalse(sut.hasOpen(hash)) + } + + @Test + fun `second pay of an in-flight hash does not fall back to confirm`() = test { + val (bolt11, _) = testInvoice() + val started = CompletableDeferred() + val hold = CompletableDeferred>() + whenever { lightningRepo.payInvoice(any(), anyOrNull(), any()) }.doSuspendableAnswer { invocation -> + val onBeforeSend = invocation.getArgument Boolean>(2) + if (!onBeforeSend()) return@doSuspendableAnswer Result.failure(PaymentAbortedBeforeSend()) + started.complete(Unit) + hold.await() + } + val session = QuickPaySession() + sut.attach(session) + backgroundScope.launch { + sut.payNow(session, QuickPayPayRequest.Bolt11(bolt11 = bolt11, amountSats = 500u)) + } + started.await() + sut.payNow(session, QuickPayPayRequest.Bolt11(bolt11 = bolt11, amountSats = 500u)) + + verify(lightningRepo, times(1)).payInvoice(any(), anyOrNull(), any()) + hold.complete(Result.success("pid")) + } + + @Test + fun `recovered submitting hash emits pending and does not pay`() = test { + val (bolt11, hash) = testInvoice() + assertNotNull(sut.reserveBound(hash, 500u).getOrThrow()) + val reloaded = repo() + val session = QuickPaySession() + + reloaded.attach(session).test { + reloaded.payNow(session, QuickPayPayRequest.Bolt11(bolt11 = bolt11, amountSats = 500u)) + val pending = assertIs(awaitItem()) + assertEquals(hash, pending.paymentHash) + } + assertEquals(250L, spentCents()) + verify(lightningRepo, never()).payInvoice(any(), anyOrNull(), any()) + verify(pendingPaymentRepo).track(hash) + } + + @Test + fun `recovered hash that ldk already succeeded emits success`() = test { + val (bolt11, hash) = testInvoice() + assertNotNull(sut.reserveBound(hash, 500u).getOrThrow()) + val reloaded = repo() + paymentRows = listOf(succeededRow(hash)) + val session = QuickPaySession() + + reloaded.attach(session).test { + reloaded.payNow(session, QuickPayPayRequest.Bolt11(bolt11 = bolt11, amountSats = 500u)) + assertIs(awaitItem()) + } + assertEquals(250L, spentCents()) + assertTrue(cacheStore.data.first().quickPayLedger!!.records.isEmpty()) + verify(lightningRepo, never()).payInvoice(any(), anyOrNull(), any()) + } + + @Test + fun `reserve persists before dispatch`() = test { + val (bolt11, _) = testInvoice() + whenever { lightningRepo.payInvoice(any(), anyOrNull(), any()) }.doSuspendableAnswer { invocation -> + val onBeforeSend = invocation.getArgument Boolean>(2) + assertEquals(250L, spentCents()) + assertEquals(1, cacheStore.data.first().quickPayLedger!!.records.size) + if (!onBeforeSend()) return@doSuspendableAnswer Result.failure(PaymentAbortedBeforeSend()) + Result.failure(LdkError(NodeException.InvalidInvoice("done"))) + } + val session = QuickPaySession() + sut.attach(session) + sut.payNow(session, QuickPayPayRequest.Bolt11(bolt11 = bolt11, amountSats = 500u)) + } + + @Test + fun `detach before dispatch aborts and releases`() = test { + val (bolt11, _) = testInvoice() + val entered = CompletableDeferred() + val gate = CompletableDeferred() + val proceeded = CompletableDeferred() + whenever { lightningRepo.payInvoice(any(), anyOrNull(), any()) }.doSuspendableAnswer { invocation -> + val onBeforeSend = invocation.getArgument Boolean>(2) + entered.complete(Unit) + withContext(NonCancellable) { + gate.await() + val ok = onBeforeSend() + proceeded.complete(ok) + if (!ok) Result.failure(PaymentAbortedBeforeSend()) else Result.success("pid") + } + } + val session = QuickPaySession() + sut.attach(session).test { + backgroundScope.launch { + sut.payNow(session, QuickPayPayRequest.Bolt11(bolt11 = bolt11, amountSats = 500u)) + } + entered.await() + sut.detach(session) + gate.complete(Unit) + assertEquals(false, proceeded.await()) + expectNoEvents() + } + assertEquals(0L, spentCents()) + assertTrue(cacheStore.data.first().quickPayLedger!!.records.isEmpty()) + verify(lightningRepo, times(1)).payInvoice(any(), anyOrNull(), any()) + } + + @Test + fun `pre-dispatch rejection refunds after dispatch`() = test { + val (bolt11, _) = testInvoice() + stubPayInvoiceFailure(NodeException.InvalidInvoice("bad")) + val session = QuickPaySession() + sut.attach(session).test { + sut.payNow(session, QuickPayPayRequest.Bolt11(bolt11 = bolt11, amountSats = 500u)) + assertIs(awaitItem()) + } + assertEquals(0L, spentCents()) + assertTrue(cacheStore.data.first().quickPayLedger!!.records.isEmpty()) + } + + @Test + fun `null payment rows mutate nothing on duplicate`() = test { + val (bolt11, _) = testInvoice() + stubPayInvoiceFailure(NodeException.DuplicatePayment("dup")) + val session = QuickPaySession() + sut.attach(session).test { + sut.payNow(session, QuickPayPayRequest.Bolt11(bolt11 = bolt11, amountSats = 500u)) + assertIs(awaitItem()) + } + assertEquals(250L, spentCents()) + assertEquals(1, cacheStore.data.first().quickPayLedger!!.records.size) + } + + @Test + fun `reconcile during live dispatched op does not steal completion`() = test { + val (bolt11, hash) = testInvoice() + val dispatched = CompletableDeferred() + whenever { lightningRepo.payInvoice(any(), anyOrNull(), any()) }.doSuspendableAnswer { invocation -> + val onBeforeSend = invocation.getArgument Boolean>(2) + if (!onBeforeSend()) return@doSuspendableAnswer Result.failure(PaymentAbortedBeforeSend()) + dispatched.complete(Unit) + Result.success("pid") + } + val session = QuickPaySession() + + sut.attach(session).test { + backgroundScope.launch { + sut.payNow(session, QuickPayPayRequest.Bolt11(bolt11 = bolt11, amountSats = 500u)) + } + dispatched.await() + paymentRows = listOf(succeededRow(hash)) + sut.reconcileAgainstLdk() + assertEquals(250L, spentCents()) + assertEquals(1, cacheStore.data.first().quickPayLedger!!.records.size) + val outcome = sut.signalCompletion(paymentId = "pid", paymentHash = hash, success = true) + assertEquals(QuickPayCompletionKind.SETTLED_SUCCESS, outcome.kind) + assertTrue(outcome.wasQuickPay) + assertIs(awaitItem()) + } + assertEquals(250L, spentCents()) + assertTrue(cacheStore.data.first().quickPayLedger!!.records.isEmpty()) + } + + private suspend fun spentCents(): Long = + cacheStore.data.first().quickPayLedger?.spentCents ?: 0L + + private suspend fun markSubmitted(invoiceHash: String, paymentId: String) { + cacheStore.update { data -> + val ledger = requireNotNull(data.quickPayLedger) + val index = ledger.records.indexOfFirst { it.invoicePaymentHash == invoiceHash } + val record = ledger.records[index].copy( + paymentId = paymentId, + phase = QuickPayRecordPhase.SUBMITTED, + ) + val next = ledger.copy(records = ledger.records.toMutableList().also { it[index] = record }) + data.copy(quickPayLedger = next) + } + } + + private suspend fun stubPayInvoiceFailure(error: NodeException) { + whenever { lightningRepo.payInvoice(any(), anyOrNull(), any()) }.doSuspendableAnswer { invocation -> + val onBeforeSend = invocation.getArgument Boolean>(2) + if (!onBeforeSend()) return@doSuspendableAnswer Result.failure(PaymentAbortedBeforeSend()) + Result.failure(LdkError(error)) + } + } + + private fun pendingRow(hash: String) = QuickPayReconcileRow( + paymentId = "pid", + invoicePaymentHash = hash, + isOutboundBolt11 = true, + status = QuickPayReconcileRow.Status.PENDING, + ) + + private fun succeededRow(hash: String) = QuickPayReconcileRow( + paymentId = "pid", + invoicePaymentHash = hash, + isOutboundBolt11 = true, + status = QuickPayReconcileRow.Status.SUCCEEDED, + ) + + private fun testInvoice(): Pair = TEST_BOLT11 to TEST_HASH + + private fun repo(): QuickPayRepo { + return QuickPayRepo( + cacheStore = cacheStore, + settingsStore = settingsStore, + currencyRepo = currencyRepo, + lightningRepo = lightningRepo, + pendingPaymentRepo = pendingPaymentRepo, + invoiceParser = QuickPayInvoiceParser { bolt11 -> + bolt11.takeIf { it == TEST_BOLT11 }?.let { TEST_HASH } + }, + paymentLookup = QuickPayPaymentLookup { + paymentRows ?: lightningRepo.listPaymentsOrNull()?.map { QuickPayReconcileRow(it) } + }, + ioDispatcher = testDispatcher, + clock = clock, + ) + } + + private fun stubZeroCentConversion(dustSats: Long) { + whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())).thenAnswer { invocation -> + val sats = invocation.getArgument(0) + val usd = if (sats == dustSats) 0.004 else 5.0 * sats.toDouble() / 1000.0 + ConvertedAmount( + value = BigDecimal.valueOf(usd), + formatted = usd.toString(), + symbol = "$", + currency = "USD", + flag = "", + sats = sats, + locale = Locale.US, + ) + } + } +} + +private class MutableClock(var instant: Instant) : Clock { + override fun now(): Instant = instant +} diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt index 38ca3ec21b..34e86502db 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt @@ -53,6 +53,20 @@ class SendPendingViewModelTest : BaseUnitTest() { assertEquals(true, pendingPaymentRepo.isActive(hash)) } + @Test + fun `init applies an already resolved hash`() = test { + pendingPaymentRepo.track(hash) + pendingPaymentRepo.resolve(PendingPaymentResolution.Success(hash, amountWithFeeSats = 510L)) + + sut.init(hash, amount) + advanceUntilIdle() + + val resolution = sut.uiState.value.resolution + assertIs(resolution) + assertEquals(510L, resolution.amountWithFeeSats) + assertNull(pendingPaymentRepo.consumeResolution(hash)) + } + @Test fun `init is idempotent`() = test { sut.init(hash, amount) @@ -95,6 +109,7 @@ class SendPendingViewModelTest : BaseUnitTest() { val resolution = sut.uiState.value.resolution assertIs(resolution) assertEquals(hash, resolution.paymentHash) + assertNull(pendingPaymentRepo.consumeResolution(hash)) } @Test diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index e58a089b26..5c99930735 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -9,6 +9,7 @@ import android.net.Uri import android.nfc.NfcAdapter import androidx.core.net.toUri import app.cash.turbine.test +import com.synonym.bitkitcore.LightningActivity import com.synonym.bitkitcore.LightningInvoice import com.synonym.bitkitcore.NetworkType import com.synonym.bitkitcore.Scanner @@ -41,6 +42,7 @@ import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.atLeast import org.mockito.kotlin.check import org.mockito.kotlin.clearInvocations +import org.mockito.kotlin.doReturn import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock @@ -95,6 +97,7 @@ import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitPaymentResult import to.bitkit.repositories.PublicPaykitRepo +import to.bitkit.repositories.QuickPayRepo import to.bitkit.repositories.SamRockRepo import to.bitkit.repositories.SettledReceiveAddress import to.bitkit.repositories.SettledReceiveInvoice @@ -156,6 +159,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val notifyPaymentReceivedHandler = mock() private val notifyChannelReadyHandler = mock() private val cacheStore = mock() + private val quickPayRepo = mock() private val transferRepo = mock() private val migrationService = mock() private val coreService = mock() @@ -224,6 +228,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(backupRepo.isRestoring).thenReturn(MutableStateFlow(false)) stubSettingsStore() whenever(cacheStore.data).thenReturn(flowOf(AppCacheData())) + whenever { quickPayRepo.canApply(any()) }.thenReturn(Result.success(false)) + whenever { quickPayRepo.hasOpen(any()) }.thenReturn(false) + whenever { + quickPayRepo.signalCompletion(anyOrNull(), anyOrNull(), any(), anyOrNull(), anyOrNull()) + }.thenReturn(to.bitkit.repositories.QuickPayCompletionOutcome.None) + whenever { activityRepo.findActivityByPaymentId(any(), any(), any(), any()) } + .thenReturn(Result.failure(Exception("activity not found"))) whenever(transferRepo.activeTransfers).thenReturn(flowOf(emptyList())) whenever(blocktankRepo.blocktankState).thenReturn(MutableStateFlow(BlocktankState())) whenever { blocktankRepo.refreshInfo() }.thenReturn(Result.success(Unit)) @@ -322,6 +333,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { notifyPaymentReceivedHandler = notifyPaymentReceivedHandler, notifyChannelReadyHandler = notifyChannelReadyHandler, cacheStore = cacheStore, + quickPayRepo = quickPayRepo, transferRepo = transferRepo, migrationService = migrationService, coreService = coreService, @@ -1716,6 +1728,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(pendingPaymentRepo).resolve(PendingPaymentResolution.Success(paymentHash)) verify(activityRepo).setContact(contactPublicKey = contactKey, forPaymentId = paymentHash) + verify(quickPayRepo).signalCompletion( + paymentId = "payment_id", + paymentHash = paymentHash, + success = true, + feePaidMsat = 10uL, + failureReason = null, + ) } @Test @@ -1741,9 +1760,175 @@ class AppViewModelSendFlowTest : BaseUnitTest() { reason = PaymentFailureReason.RETRIES_EXHAUSTED, ) ) + verify(quickPayRepo).signalCompletion( + paymentId = "payment_id", + paymentHash = paymentHash, + success = false, + feePaidMsat = null, + failureReason = PaymentFailureReason.RETRIES_EXHAUSTED, + ) assertNull(pendingContactPaymentContext(paymentHash)) } + @Test + fun `PaymentFailed with null hash still resolves pending`() = test { + val paymentHash = "pending_hash" + whenever(pendingPaymentRepo.isPending(paymentHash)).thenReturn(true) + whenever(pendingPaymentRepo.isActive(paymentHash)).thenReturn(false) + advanceUntilIdle() + + emitNodeEvent( + Event.PaymentFailed( + paymentId = paymentHash, + paymentHash = null, + reason = PaymentFailureReason.RETRIES_EXHAUSTED, + ), + ) + advanceUntilIdle() + + verify(pendingPaymentRepo).resolve( + PendingPaymentResolution.Failure( + paymentHash = paymentHash, + reason = PaymentFailureReason.RETRIES_EXHAUSTED, + ), + ) + verify(quickPayRepo).signalCompletion( + paymentId = paymentHash, + paymentHash = null, + success = false, + feePaidMsat = null, + failureReason = PaymentFailureReason.RETRIES_EXHAUSTED, + ) + } + + @Test + fun `PaymentFailed releases disk reservation when not pending`() = test { + val paymentHash = "restart_hash" + whenever(pendingPaymentRepo.isPending(paymentHash)).thenReturn(false) + + emitNodeEvent( + Event.PaymentFailed( + paymentId = "payment_id", + paymentHash = paymentHash, + reason = PaymentFailureReason.RETRIES_EXHAUSTED, + ), + ) + advanceUntilIdle() + + verify(quickPayRepo).signalCompletion( + paymentId = "payment_id", + paymentHash = paymentHash, + success = false, + feePaidMsat = null, + failureReason = PaymentFailureReason.RETRIES_EXHAUSTED, + ) + verify(pendingPaymentRepo, never()).resolve(any()) + } + + @Test + fun `PaymentSuccessful clears disk reservation when not pending`() = test { + val paymentHash = "restart_ok" + whenever(pendingPaymentRepo.isPending(paymentHash)).thenReturn(false) + whenever { + quickPayRepo.signalCompletion(anyOrNull(), anyOrNull(), any(), anyOrNull(), anyOrNull()) + }.thenReturn( + to.bitkit.repositories.QuickPayCompletionOutcome( + kind = to.bitkit.repositories.QuickPayCompletionKind.SETTLED_SUCCESS, + invoicePaymentHash = paymentHash, + ), + ) + + emitNodeEvent( + Event.PaymentSuccessful( + paymentId = "payment_id", + paymentHash = paymentHash, + paymentPreimage = "preimage", + feePaidMsat = 10uL, + ), + ) + advanceUntilIdle() + + verify(quickPayRepo).signalCompletion( + paymentId = "payment_id", + paymentHash = paymentHash, + success = true, + feePaidMsat = 10uL, + failureReason = null, + ) + verify(pendingPaymentRepo, never()).resolve(any()) + } + + @Test + fun `pending confirm lightning success keeps invoice amount`() = test { + val paymentHash = "pending_confirm_hash" + whenever(pendingPaymentRepo.isPending(paymentHash)).thenReturn(true) + whenever(pendingPaymentRepo.isActive(paymentHash)).thenReturn(false) + whenever { + quickPayRepo.signalCompletion(anyOrNull(), anyOrNull(), any(), anyOrNull(), anyOrNull()) + }.thenReturn(to.bitkit.repositories.QuickPayCompletionOutcome.None) + advanceUntilIdle() + + emitNodeEvent( + Event.PaymentSuccessful( + paymentId = "payment_id", + paymentHash = paymentHash, + paymentPreimage = "preimage", + feePaidMsat = 10uL, + ), + ) + advanceUntilIdle() + + verify(pendingPaymentRepo).resolve(PendingPaymentResolution.Success(paymentHash)) + verify(activityRepo, never()).findActivityByPaymentId(any(), any(), any(), any()) + } + + @Test + fun `pending quickpay lightning success includes settled amount`() = test { + val paymentHash = "pending_quickpay_hash" + val activityV1 = mock { + on { value } doReturn 500u + on { fee } doReturn 0u + } + val activity = mock { on { v1 } doReturn activityV1 } + whenever(pendingPaymentRepo.isPending(paymentHash)).thenReturn(true) + whenever(pendingPaymentRepo.isActive(paymentHash)).thenReturn(false) + whenever { + quickPayRepo.signalCompletion(anyOrNull(), anyOrNull(), any(), anyOrNull(), anyOrNull()) + }.thenReturn( + to.bitkit.repositories.QuickPayCompletionOutcome( + kind = to.bitkit.repositories.QuickPayCompletionKind.SETTLED_SUCCESS, + invoicePaymentHash = paymentHash, + ), + ) + whenever { activityRepo.findActivityByPaymentId(any(), any(), any(), any()) } + .thenReturn(Result.success(activity)) + advanceUntilIdle() + + emitNodeEvent( + Event.PaymentSuccessful( + paymentId = "payment_id", + paymentHash = paymentHash, + paymentPreimage = "preimage", + feePaidMsat = 10_000uL, + ), + ) + advanceUntilIdle() + + verify(pendingPaymentRepo).resolve( + PendingPaymentResolution.Success( + paymentHash = paymentHash, + amountWithFeeSats = 510L, + ), + ) + verify(quickPayRepo).signalCompletion( + paymentId = "payment_id", + paymentHash = paymentHash, + success = true, + feePaidMsat = 10_000uL, + failureReason = null, + ) + } + @Test fun `active lightning send failure navigates to failure screen`() = test { val bolt11 = "lnbcrt1activefailure" @@ -1786,6 +1971,61 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } } + @Test + fun `in-flight QuickPay failure does not navigate to confirm error`() = test { + val bolt11 = "lnbcrt1quickpayfail" + enableQuickPay() + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + sut.onScanResult(bolt11) + advanceUntilIdle() + + sut.sendEffect.test { + emitNodeEvent( + Event.PaymentFailed( + paymentId = "payment_id", + paymentHash = "010203", + reason = PaymentFailureReason.ROUTE_NOT_FOUND, + ), + ) + advanceUntilIdle() + expectNoEvents() + } + } + + @Test + fun `confirm failure still navigates after QuickPay fallback`() = test { + val bolt11 = "lnbcrt1quickpayfallback" + val errorMessage = "Bitkit could not find a route" + whenever(context.getString(R.string.wallet__payment_route_not_found)).thenReturn(errorMessage) + enableQuickPay() + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + sut.onScanResult(bolt11) + advanceUntilIdle() + sut.resetQuickPay() + + sut.sendEffect.test { + emitNodeEvent( + Event.PaymentFailed( + paymentId = "payment_id", + paymentHash = "010203", + reason = PaymentFailureReason.ROUTE_NOT_FOUND, + ), + ) + advanceUntilIdle() + assertEquals( + SendEffect.NavigateToError( + SendFailureDetails( + message = errorMessage, + failureType = "routeNotFound", + resetRoutingCachesOnRetry = true, + paymentRequest = bolt11, + ) + ), + awaitItem(), + ) + } + } + @Test fun `received lightning payment closes the active receive sheet after wallet invoice is cleared`() = test { walletState.value = WalletState(bolt11 = "settled-invoice") @@ -2163,7 +2403,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `main scanner lightning scan opens QuickPay when enabled`() = test { val bolt11 = "lnbcrt1scannerquickpay" - enableQuickPay(thresholdSats = 1000u) + enableQuickPay() stubLightningScan(bolt11 = bolt11, amountSats = 500u) sut.showScannerSheet() @@ -2171,30 +2411,43 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() assertEquals(QuickPayData.Bolt11(sats = 500u, bolt11 = bolt11), sut.quickPayData.value) - assertEquals(SendMethod.ONCHAIN, sut.sendUiState.value.payMethod) - assertNull(sut.sendUiState.value.decodedInvoice) + assertEquals(SendMethod.LIGHTNING, sut.sendUiState.value.payMethod) + assertEquals(bolt11, sut.sendUiState.value.decodedInvoice?.bolt11) assertEquals(Sheet.Send(SendRoute.QuickPay), sut.currentSheet.value) } @Test fun `lightning scan uses QuickPay when enabled`() = test { val bolt11 = "lnbcrt1quickpay" - enableQuickPay(thresholdSats = 1000u) + enableQuickPay() stubLightningScan(bolt11 = bolt11, amountSats = 500u) sut.onScanResult(bolt11) advanceUntilIdle() assertEquals(QuickPayData.Bolt11(sats = 500u, bolt11 = bolt11), sut.quickPayData.value) - assertEquals(SendMethod.ONCHAIN, sut.sendUiState.value.payMethod) - assertNull(sut.sendUiState.value.decodedInvoice) + assertEquals(SendMethod.LIGHTNING, sut.sendUiState.value.payMethod) + assertEquals(bolt11, sut.sendUiState.value.decodedInvoice?.bolt11) assertEquals(Sheet.Send(SendRoute.QuickPay), sut.currentSheet.value) } @Test - fun `lightning scan uses QuickPay when PIN is required for payments`() = test { + fun `hiding send sheet clears quickPayData`() = test { + val bolt11 = "lnbcrt1quickpayhide" + enableQuickPay() + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + + sut.onScanResult(bolt11) + advanceUntilIdle() + sut.hideSheet() + + assertNull(sut.quickPayData.value) + } + + @Test + fun `lightning scan uses QuickPay when PIN is required for payments under daily cap`() = test { val bolt11 = "lnbcrt1quickpaypin" - enableQuickPay(thresholdSats = 1000u) + enableQuickPay() settingsData.value = settingsData.value.copy( isPinEnabled = true, isPinForPaymentsEnabled = true, @@ -2209,10 +2462,54 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.Send(SendRoute.QuickPay), sut.currentSheet.value) } + @Test + fun `lightning scan uses QuickPay when PIN is on without PIN for payments`() = test { + val bolt11 = "lnbcrt1quickpayunlocked" + enableQuickPay() + settingsData.value = settingsData.value.copy(isPinEnabled = true) + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + sut.setIsAuthenticated(true) + + sut.onScanResult(bolt11) + advanceUntilIdle() + + assertEquals(QuickPayData.Bolt11(sats = 500u, bolt11 = bolt11), sut.quickPayData.value) + assertEquals(Sheet.Send(SendRoute.QuickPay), sut.currentSheet.value) + } + + @Test + fun `lightning scan skips QuickPay when daily spend cap is exceeded`() = test { + val bolt11 = "lnbcrt1quickpaycap" + enableQuickPay(canApply = false) + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + sut.setIsAuthenticated(true) + + sut.onScanResult(bolt11) + advanceUntilIdle() + + assertNull(sut.quickPayData.value) + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + } + + @Test + fun `lightning scan uses QuickPay when hash is already open`() = test { + val bolt11 = "lnbcrt1quickpayopen" + enableQuickPay(canApply = false) + whenever { quickPayRepo.hasOpen(any()) }.thenReturn(true) + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + sut.setIsAuthenticated(true) + + sut.onScanResult(bolt11) + advanceUntilIdle() + + assertEquals(QuickPayData.Bolt11(sats = 500u, bolt11 = bolt11), sut.quickPayData.value) + assertEquals(Sheet.Send(SendRoute.QuickPay), sut.currentSheet.value) + } + @Test fun `QuickPay eligible scan remains deferred until authenticated`() = test { val bolt11 = "lnbcrt1lockedscan" - enableQuickPay(thresholdSats = 1_000u) + enableQuickPay() settingsData.value = settingsData.value.copy( isPinEnabled = true, isPinForPaymentsEnabled = true, @@ -2505,7 +2802,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `contact lightning payment skips QuickPay and opens confirm`() = test { val bolt11 = "lnbcrt1contact" - enableQuickPay(thresholdSats = 1000u) + enableQuickPay() stubLightningScan(bolt11 = bolt11, amountSats = 500u) sut.openContactPayment(paymentRequest = bolt11, publicKey = "pubkycontact") @@ -3216,9 +3513,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() } - private fun enableQuickPay(thresholdSats: ULong) { + private fun enableQuickPay(canApply: Boolean = true) { settingsData.value = SettingsData(isQuickPayEnabled = true, quickPayAmount = 5) - whenever(currencyRepo.convertFiatToSats(5.0, "USD")).thenReturn(Result.success(thresholdSats)) + whenever { quickPayRepo.canApply(any()) }.thenReturn(Result.success(canApply)) } private suspend fun stubLightningScan(bolt11: String, amountSats: ULong) { diff --git a/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt new file mode 100644 index 0000000000..95c447ab2d --- /dev/null +++ b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt @@ -0,0 +1,147 @@ +package to.bitkit.viewmodels + +import android.content.Context +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import to.bitkit.R +import to.bitkit.models.NodeLifecycleState +import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.LightningState +import to.bitkit.repositories.QuickPayConversionError +import to.bitkit.repositories.QuickPayPayRequest +import to.bitkit.repositories.QuickPayRepo +import to.bitkit.repositories.QuickPaySession +import to.bitkit.repositories.QuickPaySessionEvent +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertIs + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class QuickPayViewModelTest : BaseUnitTest() { + private val context: Context = mock() + private val lightningRepo: LightningRepo = mock() + private val quickPayRepo: QuickPayRepo = mock() + private val events = MutableSharedFlow(extraBufferCapacity = 8) + + private lateinit var sut: QuickPayViewModel + + @Before + fun setUp() { + whenever(context.getString(any())).thenReturn("error") + whenever(context.getString(R.string.wallet__send_quickpay__currency_conversion)).thenReturn("conversion") + whenever(lightningRepo.lightningState).thenReturn( + MutableStateFlow(LightningState(nodeLifecycleState = NodeLifecycleState.Running)), + ) + whenever(quickPayRepo.attach(any())).thenReturn(events) + sut = QuickPayViewModel( + context = context, + lightningRepo = lightningRepo, + quickPayRepo = quickPayRepo, + ) + } + + @Test + fun `success event maps to ui success`() = test { + val session = QuickPaySession() + sut.attach(session) + events.emit(QuickPaySessionEvent.Success(paymentHash = "hash1", amountWithFee = 501L)) + advanceUntilIdle() + + val success = assertIs(sut.uiState.value.result) + assertEquals("hash1", success.paymentHash) + assertEquals(501L, success.amountWithFee) + } + + @Test + fun `pending event maps to ui pending`() = test { + val session = QuickPaySession() + sut.attach(session) + events.emit( + QuickPaySessionEvent.Pending( + paymentHash = "hash1", + amount = 500L, + paymentRequest = "lnbcrt1test", + ), + ) + advanceUntilIdle() + + val pending = assertIs(sut.uiState.value.result) + assertEquals("hash1", pending.paymentHash) + } + + @Test + fun `pay forwards to repo`() = test { + val session = QuickPaySession() + sut.attach(session) + val data = QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test") + + sut.pay(session, data) + + verify(quickPayRepo).pay( + session, + QuickPayPayRequest.Bolt11(bolt11 = "lnbcrt1test", amountSats = 500u), + ) + verify(quickPayRepo, never()).signalCompletion(any(), any(), any(), any(), any()) + } + + @Test + fun `pay ignores re-entry after a result`() = test { + val session = QuickPaySession() + sut.attach(session) + events.emit(QuickPaySessionEvent.FallBackToConfirm) + advanceUntilIdle() + + sut.pay(session, QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) + sut.pay(session, QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) + + verify(quickPayRepo, never()).pay(any(), any()) + } + + @Test + fun `conversion failure uses currency conversion message`() = test { + val session = QuickPaySession() + sut.attach(session) + events.emit(QuickPaySessionEvent.Error(QuickPayConversionError(), null)) + advanceUntilIdle() + + val error = assertIs(sut.uiState.value.result) + assertEquals("conversion", error.failure.message) + } + + @Test + fun `stale detach does not detach a newer session`() = test { + val old = QuickPaySession() + val next = QuickPaySession() + sut.attach(old) + sut.attach(next) + sut.detach(old) + + verify(quickPayRepo, times(1)).detach(old) + verify(quickPayRepo, never()).detach(next) + } + + @Test + fun `viewmodel has no settlement methods on the repo besides signalCompletion from events`() = test { + val session = QuickPaySession() + sut.attach(session) + sut.pay(session, QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) + advanceUntilIdle() + + verify(quickPayRepo, never()).signalCompletion(any(), any(), any(), any(), any()) + } +} diff --git a/changelog.d/next/1159.security.md b/changelog.d/next/1159.security.md new file mode 100644 index 0000000000..3a5339f9fa --- /dev/null +++ b/changelog.d/next/1159.security.md @@ -0,0 +1 @@ +QuickPay stays PIN-free under a configurable daily spend limit; once that limit is reached, payments open Confirm instead.