From 068fb917d7c09f0d62ed014721183954671698b3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 15 Aug 2026 14:18:33 +0200 Subject: [PATCH 01/71] fix: require payment pin for quickpay --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 12 ++++++++++- .../viewmodels/AppViewModelSendFlowTest.kt | 21 ++++++++++++++++--- .../next/require-payment-pin.security.md | 1 + 3 files changed, 30 insertions(+), 4 deletions(-) create mode 100644 changelog.d/next/require-payment-pin.security.md diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 849726cc2..842bf4fc8 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -74,6 +74,7 @@ import org.lightningdevkit.ldknode.Txid import to.bitkit.BuildConfig import to.bitkit.R import to.bitkit.data.CacheStore +import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.data.resetPin @@ -2603,7 +2604,7 @@ class AppViewModel @Inject constructor( if (hasActiveContactPaymentContext()) return false val settings = settingsStore.data.first() - if (!settings.isQuickPayEnabled || amountSats == 0uL) return false + if (!canApplyQuickPay(settings, amountSats)) return false val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD").getOrNull() ?: return false @@ -2636,6 +2637,15 @@ class AppViewModel @Inject constructor( return false } + private fun canApplyQuickPay(settings: SettingsData, amountSats: ULong): Boolean { + if (!settings.isQuickPayEnabled || amountSats == 0uL) return false + if (settings.isPinEnabled && settings.isPinForPaymentsEnabled) { + Logger.debug("Skipping QuickPay because PIN is required for payments", context = TAG) + return false + } + return true + } + private fun resetAmountInput() { _sendUiState.update { state -> state.copy( diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 6822141c6..1fbd60d6a 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -2155,7 +2155,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `lightning scan uses QuickPay when PIN is required for payments`() = test { + fun `lightning scan skips QuickPay when PIN is required for payments`() = test { val bolt11 = "lnbcrt1quickpaypin" enableQuickPay(thresholdSats = 1000u) settingsData.value = settingsData.value.copy( @@ -2168,6 +2168,21 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.onScanResult(bolt11) advanceUntilIdle() + assertNull(sut.quickPayData.value) + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + } + + @Test + fun `lightning scan uses QuickPay when PIN is on without PIN for payments`() = test { + val bolt11 = "lnbcrt1quickpayunlocked" + enableQuickPay(thresholdSats = 1000u) + 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) } @@ -2192,8 +2207,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setIsAuthenticated(true) advanceUntilIdle() - assertEquals(QuickPayData.Bolt11(sats = 500u, bolt11 = bolt11), sut.quickPayData.value) - assertEquals(Sheet.Send(SendRoute.QuickPay), sut.currentSheet.value) + assertNull(sut.quickPayData.value) + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) verify(coreService).decode(bolt11) } diff --git a/changelog.d/next/require-payment-pin.security.md b/changelog.d/next/require-payment-pin.security.md new file mode 100644 index 000000000..dbb88e6bc --- /dev/null +++ b/changelog.d/next/require-payment-pin.security.md @@ -0,0 +1 @@ +QuickPay now asks for the payment PIN when that setting is enabled. From 76cb9ef2fc367e481baea0bc5448473c800f5589 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 15 Aug 2026 14:26:10 +0200 Subject: [PATCH 02/71] chore: rename changelog fragment --- .../next/{require-payment-pin.security.md => 1159.security.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{require-payment-pin.security.md => 1159.security.md} (100%) diff --git a/changelog.d/next/require-payment-pin.security.md b/changelog.d/next/1159.security.md similarity index 100% rename from changelog.d/next/require-payment-pin.security.md rename to changelog.d/next/1159.security.md From 01cc20880fe28d478b322cda8dc2e3e762812959 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 15 Aug 2026 15:43:38 +0200 Subject: [PATCH 03/71] fix: add quickpay daily spend limit --- .../main/java/to/bitkit/data/CacheStore.kt | 17 +++++ .../main/java/to/bitkit/data/SettingsStore.kt | 1 + app/src/main/java/to/bitkit/ext/DateTime.kt | 6 ++ .../quickPay/QuickPaySettingsScreen.kt | 35 +++++++++- .../java/to/bitkit/viewmodels/AppViewModel.kt | 66 ++++++++++--------- .../to/bitkit/viewmodels/QuickPayViewModel.kt | 13 ++++ .../to/bitkit/viewmodels/SettingsViewModel.kt | 9 +++ app/src/main/res/values/strings.xml | 2 + .../java/to/bitkit/data/CacheStoreTest.kt | 25 +++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 48 ++++++++++++-- changelog.d/next/1159.security.md | 2 +- 11 files changed, 185 insertions(+), 39 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index dc337a825..0a8fe8e5d 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -139,6 +139,21 @@ class CacheStore @Inject constructor( store.updateData { it.copy(backgroundReceive = null) } } + suspend fun quickPaySpentUsdForDay(dayKey: String): Double { + val data = store.data.first() + return if (data.quickPaySpendDayKey == dayKey) data.quickPaySpentUsdToday else 0.0 + } + + suspend fun recordQuickPaySpendUsd(amountUsd: Double, dayKey: String) { + store.updateData { + if (it.quickPaySpendDayKey != dayKey) { + it.copy(quickPaySpendDayKey = dayKey, quickPaySpentUsdToday = amountUsd) + } else { + it.copy(quickPaySpentUsdToday = it.quickPaySpentUsdToday + amountUsd) + } + } + } + suspend fun reset() { store.updateData { AppCacheData() } Logger.info("Deleted all app cached data.") @@ -164,6 +179,8 @@ data class AppCacheData( val backgroundReceive: NewTransactionSheetDetails? = null, val addressSearchLastUsedReceiveIndexes: Map = mapOf(), val addressSearchLastUsedChangeIndexes: Map = mapOf(), + val quickPaySpendDayKey: String = "", + val quickPaySpentUsdToday: Double = 0.0, ) { 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 5904e4881..eddec111d 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/ext/DateTime.kt b/app/src/main/java/to/bitkit/ext/DateTime.kt index 8358e3871..145b2908d 100644 --- a/app/src/main/java/to/bitkit/ext/DateTime.kt +++ b/app/src/main/java/to/bitkit/ext/DateTime.kt @@ -39,6 +39,12 @@ fun nowMillis(clock: Clock = Clock.System): Long = clock.now().toEpochMillisecon @OptIn(ExperimentalTime::class) fun Clock.nowMs(): Long = now().toEpochMilliseconds() +@OptIn(ExperimentalTime::class) +fun quickPaySpendDayKey( + clock: Clock = Clock.System, + timeZone: TimeZone = TimeZone.currentSystemDefault(), +): String = clock.now().toLocalDateTime(timeZone).date.toString() + fun nowTimestamp(): Instant = Instant.now().truncatedTo(ChronoUnit.SECONDS) fun dateTimeFormatterOf( 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 145bea29e..0ada40b55 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 @@ -38,12 +38,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 +55,15 @@ 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 ScreenColumn { AppTopBar( @@ -98,7 +105,32 @@ fun QuickPaySettingsScreenContent( value = quickPayAmount, steps = sliderSteps, onValueChange = onQuickPayAmountChange, - modifier = Modifier.testTag("quickpay_amount_slider") + modifier = Modifier.testTag("QuickpayAmountSlider") + ) + + Spacer(modifier = Modifier.height(32.dp)) + + Caption13Up( + text = stringResource(R.string.settings__quickpay__settings__daily_label), + color = Colors.White64, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + BodyM( + text = stringResource(R.string.settings__quickpay__settings__daily_text) + .replace("{limit}", dailyLimitUsd.toString()) + .replace("{multiplier}", quickPayDailyLimitMultiplier.toString()), + color = Colors.White64, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + StepSlider( + value = quickPayDailyLimitMultiplier, + steps = dailyLimitSteps, + onValueChange = onQuickPayDailyLimitMultiplierChange, + modifier = Modifier.testTag("QuickpayDailyLimitSlider") ) Spacer(modifier = Modifier.weight(1f)) @@ -128,6 +160,7 @@ private fun Preview() { QuickPaySettingsScreenContent( isQuickPayEnabled = true, quickPayAmount = 5, + quickPayDailyLimitMultiplier = 5, ) } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 842bf4fc8..fb54acf8b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -74,7 +74,6 @@ import org.lightningdevkit.ldknode.Txid import to.bitkit.BuildConfig import to.bitkit.R import to.bitkit.data.CacheStore -import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.data.resetPin @@ -98,6 +97,7 @@ import to.bitkit.ext.maxSendableSat import to.bitkit.ext.maxWithdrawableSat import to.bitkit.ext.minSendableSat import to.bitkit.ext.minWithdrawableSat +import to.bitkit.ext.quickPaySpendDayKey import to.bitkit.ext.rawId import to.bitkit.ext.removeSpaces import to.bitkit.ext.runSuspendCatching @@ -2601,48 +2601,52 @@ class AppViewModel @Inject constructor( lnurlPay: LnurlPayData? = null, invoice: LightningInvoice? = null, ): Boolean { - if (hasActiveContactPaymentContext()) return false - - val settings = settingsStore.data.first() - if (!canApplyQuickPay(settings, amountSats)) return false - - val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD").getOrNull() - ?: return false - - if (amountSats <= quickPayAmountSats) { - Logger.info("Using QuickPay: $amountSats sats <= $quickPayAmountSats sats threshold", context = TAG) + if (!canApplyQuickPay(amountSats)) return false - val quickPayData: QuickPayData = when { - lnurlPay != null -> { - QuickPayData.LnurlPay( - sats = amountSats, - data = lnurlPay, - ) - } + Logger.info("Using QuickPay for '$amountSats' sats", 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) + } + } - Logger.debug("QuickPayData: $quickPayData", context = TAG) + _quickPayData.update { quickPayData } - navigateToSendRoute(fromMainScanner, SendRoute.QuickPay, SendEffect.NavigateToQuickPay) - return true - } + Logger.debug("QuickPayData: $quickPayData", context = TAG) - return false + navigateToSendRoute(fromMainScanner, SendRoute.QuickPay, SendEffect.NavigateToQuickPay) + return true } - private fun canApplyQuickPay(settings: SettingsData, amountSats: ULong): Boolean { + private suspend fun canApplyQuickPay(amountSats: ULong): Boolean { + if (hasActiveContactPaymentContext()) return false + + val settings = settingsStore.data.first() if (!settings.isQuickPayEnabled || amountSats == 0uL) return false - if (settings.isPinEnabled && settings.isPinForPaymentsEnabled) { - Logger.debug("Skipping QuickPay because PIN is required for payments", context = TAG) + + val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD").getOrNull() + val amountUsd = currencyRepo.convertSatsToFiat(amountSats.toLong(), "USD").getOrNull()?.value?.toDouble() + if (quickPayAmountSats == null || amountUsd == null || amountSats > quickPayAmountSats) return false + + val dailyCapUsd = settings.quickPayAmount.toDouble() * settings.quickPayDailyLimitMultiplier + val spentUsdToday = cacheStore.quickPaySpentUsdForDay(quickPaySpendDayKey()) + if (spentUsdToday + amountUsd > dailyCapUsd) { + Logger.info( + "Skipping QuickPay: daily spend '$spentUsdToday' + '$amountUsd' exceeds cap '$dailyCapUsd'", + context = TAG, + ) return false } + return true } diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index 0599d4ae4..e4fb98fa0 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -11,10 +11,13 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.PaymentId +import to.bitkit.data.CacheStore import to.bitkit.ext.WatchResult import to.bitkit.ext.callbackAmountMsats +import to.bitkit.ext.quickPaySpendDayKey import to.bitkit.ext.toUserMessage import to.bitkit.ext.watchUntil +import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.PaymentPendingException import to.bitkit.repositories.PendingPaymentRepo @@ -27,6 +30,8 @@ class QuickPayViewModel @Inject constructor( @ApplicationContext private val context: Context, private val lightningRepo: LightningRepo, private val pendingPaymentRepo: PendingPaymentRepo, + private val currencyRepo: CurrencyRepo, + private val cacheStore: CacheStore, ) : ViewModel() { companion object { @@ -65,6 +70,7 @@ class QuickPayViewModel @Inject constructor( sendLightning(bolt11, amount) .onSuccess { paymentHash -> Logger.info("QuickPay lightning payment successful") + recordQuickPaySpend(displaySats) _uiState.update { it.copy( result = QuickPayResult.Success( @@ -77,6 +83,7 @@ class QuickPayViewModel @Inject constructor( if (error is PaymentPendingException) { Logger.info("QuickPay lightning payment pending", context = TAG) pendingPaymentRepo.track(error.paymentHash) + recordQuickPaySpend(displaySats) _uiState.update { it.copy( result = QuickPayResult.Pending( @@ -96,6 +103,12 @@ class QuickPayViewModel @Inject constructor( } } + private suspend fun recordQuickPaySpend(amountSats: ULong) { + val amountUsd = currencyRepo.convertSatsToFiat(amountSats.toLong(), "USD").getOrNull()?.value?.toDouble() + ?: return + cacheStore.recordQuickPaySpendUsd(amountUsd, quickPaySpendDayKey()) + } + private suspend fun sendLightning( bolt11: String, amount: ULong? = null, diff --git a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt index 9410f5c5f..e406efd3e 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 f0682904c..3cf21d8f5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -933,6 +933,8 @@ 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 confirmation or PIN ({multiplier}× your threshold). After that, payments open Confirm. Quickpay threshold * 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*. diff --git a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt index 915f35d3b..90855eb81 100644 --- a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt +++ b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt @@ -91,4 +91,29 @@ class CacheStoreTest : BaseUnitTest() { sut.data.first().deletedActivities, ) } + + @Test + fun `quickPaySpentUsdForDay returns spend for matching day key`() = test { + sut.recordQuickPaySpendUsd(amountUsd = 3.5, dayKey = "2026-08-15") + + assertEquals(3.5, sut.quickPaySpentUsdForDay("2026-08-15")) + } + + @Test + fun `quickPaySpentUsdForDay returns zero for a different day key`() = test { + sut.recordQuickPaySpendUsd(amountUsd = 12.0, dayKey = "2026-08-14") + + assertEquals(0.0, sut.quickPaySpentUsdForDay("2026-08-15")) + } + + @Test + fun `recordQuickPaySpendUsd accumulates on the same day and resets on a new day`() = test { + sut.recordQuickPaySpendUsd(amountUsd = 2.0, dayKey = "2026-08-15") + sut.recordQuickPaySpendUsd(amountUsd = 1.5, dayKey = "2026-08-15") + assertEquals(3.5, sut.quickPaySpentUsdForDay("2026-08-15")) + + sut.recordQuickPaySpendUsd(amountUsd = 4.0, dayKey = "2026-08-16") + assertEquals(4.0, sut.quickPaySpentUsdForDay("2026-08-16")) + assertEquals(0.0, sut.quickPaySpentUsdForDay("2026-08-15")) + } } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 1fbd60d6a..1f7dc0d13 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -60,6 +60,7 @@ import to.bitkit.domain.commands.NotifyChannelReadyHandler import to.bitkit.domain.commands.NotifyPaymentReceived import to.bitkit.domain.commands.NotifyPaymentReceivedHandler import to.bitkit.models.BalanceState +import to.bitkit.models.ConvertedAmount import to.bitkit.models.HwWalletReceivedTx import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection @@ -117,8 +118,10 @@ import to.bitkit.usecases.FormatMoneyValue import to.bitkit.usecases.RefreshContactPaykitReceiversUseCase import to.bitkit.utils.AppError import to.bitkit.utils.timedsheets.TimedSheetManager +import java.math.BigDecimal import java.net.URLEncoder import java.nio.charset.StandardCharsets +import java.util.Locale import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull @@ -220,6 +223,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(backupRepo.isRestoring).thenReturn(MutableStateFlow(false)) stubSettingsStore() whenever(cacheStore.data).thenReturn(flowOf(AppCacheData())) + whenever { cacheStore.quickPaySpentUsdForDay(any()) }.thenReturn(0.0) whenever(transferRepo.activeTransfers).thenReturn(flowOf(emptyList())) whenever(blocktankRepo.blocktankState).thenReturn(MutableStateFlow(BlocktankState())) whenever { blocktankRepo.refreshInfo() }.thenReturn(Result.success(Unit)) @@ -2155,7 +2159,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `lightning scan skips QuickPay when PIN is required for payments`() = test { + fun `lightning scan uses QuickPay when PIN is required for payments under daily cap`() = test { val bolt11 = "lnbcrt1quickpaypin" enableQuickPay(thresholdSats = 1000u) settingsData.value = settingsData.value.copy( @@ -2168,8 +2172,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.onScanResult(bolt11) advanceUntilIdle() - assertNull(sut.quickPayData.value) - assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + assertEquals(QuickPayData.Bolt11(sats = 500u, bolt11 = bolt11), sut.quickPayData.value) + assertEquals(Sheet.Send(SendRoute.QuickPay), sut.currentSheet.value) } @Test @@ -2187,6 +2191,21 @@ class AppViewModelSendFlowTest : BaseUnitTest() { 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(thresholdSats = 1000u, spentUsdToday = 24.0) + settingsData.value = settingsData.value.copy(quickPayDailyLimitMultiplier = 5) + 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 `QuickPay eligible scan remains deferred until authenticated`() = test { val bolt11 = "lnbcrt1lockedscan" @@ -2207,8 +2226,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setIsAuthenticated(true) advanceUntilIdle() - assertNull(sut.quickPayData.value) - assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + assertEquals(QuickPayData.Bolt11(sats = 500u, bolt11 = bolt11), sut.quickPayData.value) + assertEquals(Sheet.Send(SendRoute.QuickPay), sut.currentSheet.value) verify(coreService).decode(bolt11) } @@ -3194,9 +3213,26 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() } - private fun enableQuickPay(thresholdSats: ULong) { + private fun enableQuickPay( + thresholdSats: ULong, + spentUsdToday: Double = 0.0, + ) { settingsData.value = SettingsData(isQuickPayEnabled = true, quickPayAmount = 5) whenever(currencyRepo.convertFiatToSats(5.0, "USD")).thenReturn(Result.success(thresholdSats)) + whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())).thenAnswer { invocation -> + val sats = invocation.getArgument(0) + val usd = 5.0 * sats.toDouble() / thresholdSats.toDouble() + ConvertedAmount( + value = BigDecimal.valueOf(usd), + formatted = usd.toString(), + symbol = "$", + currency = "USD", + flag = "", + sats = sats, + locale = Locale.US, + ) + } + whenever { cacheStore.quickPaySpentUsdForDay(any()) }.thenReturn(spentUsdToday) } private suspend fun stubLightningScan(bolt11: String, amountSats: ULong) { diff --git a/changelog.d/next/1159.security.md b/changelog.d/next/1159.security.md index dbb88e6bc..3a5339f9f 100644 --- a/changelog.d/next/1159.security.md +++ b/changelog.d/next/1159.security.md @@ -1 +1 @@ -QuickPay now asks for the payment PIN when that setting is enabled. +QuickPay stays PIN-free under a configurable daily spend limit; once that limit is reached, payments open Confirm instead. From 99b47d87d0d5378afb2580d5ead8a5bcad5c0163 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 15 Aug 2026 15:52:28 +0200 Subject: [PATCH 04/71] fix: show multiplier steps as times --- app/src/main/java/to/bitkit/ui/components/Slider.kt | 3 ++- .../to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) 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 8c6d12405..fd7f07b6d 100644 --- a/app/src/main/java/to/bitkit/ui/components/Slider.kt +++ b/app/src/main/java/to/bitkit/ui/components/Slider.kt @@ -60,6 +60,7 @@ fun StepSlider( steps: ImmutableList, onValueChange: (Int) -> Unit, modifier: Modifier = Modifier, + formatLabel: (Int) -> String = { "$$it" }, ) { val density = LocalDensity.current val coroutineScope = rememberCoroutineScope() @@ -228,7 +229,7 @@ fun StepSlider( steps.forEachIndexed { index, step -> if (stepPositions.isNotEmpty() && index < stepPositions.size) { Caption13Up( - text = "$$step", + text = formatLabel(step), color = Colors.White64, textAlign = TextAlign.Center, modifier = Modifier 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 0ada40b55..23992e2fe 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 @@ -130,6 +130,7 @@ fun QuickPaySettingsScreenContent( value = quickPayDailyLimitMultiplier, steps = dailyLimitSteps, onValueChange = onQuickPayDailyLimitMultiplierChange, + formatLabel = { "${it}×" }, modifier = Modifier.testTag("QuickpayDailyLimitSlider") ) From fbe9321e3649bfe51311c4eb0f30bf2189ba1bb6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 15 Aug 2026 16:46:32 +0200 Subject: [PATCH 05/71] fix: include steplider labels in layout bounds --- .../java/to/bitkit/ui/components/Slider.kt | 239 ++++++++++-------- 1 file changed, 128 insertions(+), 111 deletions(-) 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 fd7f07b6d..6b353b306 100644 --- a/app/src/main/java/to/bitkit/ui/components/Slider.kt +++ b/app/src/main/java/to/bitkit/ui/components/Slider.kt @@ -117,130 +117,147 @@ fun StepSlider( 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), - ) + Column(modifier = Modifier.fillMaxWidth()) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(KNOB_SIZE_DP.dp) + ) { + // 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]) + } } - onValueChange(steps[closestIndex]) - } - } - ) { - val trackY = center.y - val trackHeight = density.run { TRACK_HEIGHT_DP.dp.toPx() } - val cornerRadius = density.run { 3.dp.toPx() } + ) { + 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), - ) - - // 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), - ) - } + // Draw inactive track + drawRoundRect( + color = Colors.Green32, + topLeft = Offset(0f, trackY - trackHeight / 2), + size = Size(size.width, trackHeight), + cornerRadius = CornerRadius(cornerRadius), + ) - // 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() } + // 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), + ) + } - stepPositions.forEach { position -> - drawRoundRect( - color = Colors.White, - topLeft = Offset(position - markerWidth / 2, trackY - markerHeight / 2), - size = Size(markerWidth, markerHeight), - cornerRadius = CornerRadius(markerRadius), - ) - } - } + // 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() } - // 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), - ) - } - onValueChange(steps[closestIndex]) - }, - ) { _, dragAmount -> - coroutineScope.launch { - val newPosition = (knobPosition.value + dragAmount.x) - .coerceIn(0f, sliderWidth.toFloat()) - knobPosition.snapTo(newPosition) - } + 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) - ) - } - } - // Step labels - steps.forEachIndexed { index, step -> - if (stepPositions.isNotEmpty() && index < stepPositions.size) { - Caption13Up( - text = formatLabel(step), - color = Colors.White64, - textAlign = TextAlign.Center, + // Knob + Box( 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(), + 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), + ) + } + onValueChange(steps[closestIndex]) + }, + ) { _, dragAmount -> + coroutineScope.launch { + val newPosition = (knobPosition.value + dragAmount.x) + .coerceIn(0f, sliderWidth.toFloat()) + knobPosition.snapTo(newPosition) + } + } + } + ) { + // 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) + ) + } + } + } + + // Labels participate in layout height (horizontal offset only) + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp) + ) { + steps.forEachIndexed { index, step -> + Caption13Up( + text = formatLabel(step), + color = Colors.White64, + textAlign = TextAlign.Center, + modifier = Modifier + .width(KNOB_SIZE_DP.dp) + .offset { + val x = if (index < stepPositions.size) { + ( + stepPositions[index] - + with(density) { KNOB_SIZE_DP.dp.toPx() / 2 } + ).roundToInt() + } else { + 0 + } + IntOffset(x = x, y = 0) + } + ) + } } } } From d90dfb1c53dcd94584d600af91c415eb0e593153 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 15 Aug 2026 17:34:33 +0200 Subject: [PATCH 06/71] fix: layout steplider labels from constraints --- .../java/to/bitkit/ui/components/Slider.kt | 170 +++++++++--------- 1 file changed, 89 insertions(+), 81 deletions(-) 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 6b353b306..f1d40fc85 100644 --- a/app/src/main/java/to/bitkit/ui/components/Slider.kt +++ b/app/src/main/java/to/bitkit/ui/components/Slider.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -21,6 +22,7 @@ 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.setValue @@ -31,10 +33,12 @@ 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.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity 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,6 +56,7 @@ 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 @@ -64,78 +69,69 @@ fun StepSlider( ) { val density = LocalDensity.current val coroutineScope = rememberCoroutineScope() - - var sliderWidth by remember { mutableIntStateOf(0) } val knobPosition = remember { Animatable(0f) } + var isDragging by remember { mutableStateOf(false) } - // Calculate step positions (evenly spaced) - val stepPositions = remember(steps, sliderWidth) { - if (sliderWidth == 0) { - emptyList() - } else { - steps.indices.map { index -> + BoxWithConstraints(modifier = modifier.fillMaxWidth()) { + val sliderWidth = constraints.maxWidth.toFloat() + val stepPositions = remember(steps, sliderWidth) { + if (sliderWidth <= 0f) { + emptyList() + } else { val numSteps = (steps.size - 1).coerceAtLeast(1) - (index.toFloat() / numSteps) * sliderWidth - } - } - } - - // Initialize knob position when value changes - LaunchedEffect(value, stepPositions) { - if (stepPositions.isNotEmpty()) { - val valueIndex = steps.indexOf(value) - if (valueIndex >= 0) { - knobPosition.snapTo(stepPositions[valueIndex]) + steps.indices.map { index -> (index.toFloat() / numSteps) * sliderWidth } } } - } + val valueIndex = steps.indexOf(value).takeIf { it >= 0 } ?: 0 + val settledX = stepPositions.getOrElse(valueIndex) { 0f } + val knobX = if (isDragging) knobPosition.value else settledX - // Find closest step position - fun findClosestStep(currentPosition: Float): Pair { - if (stepPositions.isEmpty()) return 0f to 0 + 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]) + 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 + stepPositions.forEachIndexed { index, position -> + val distance = abs(currentPosition - position) + if (distance < minDistance) { + minDistance = distance + closestPosition = position + closestIndex = index + } } - } - return closestPosition to closestIndex - } + return closestPosition to closestIndex + } - Box( - modifier = modifier - .fillMaxWidth() - .onGloballyPositioned { coordinates -> - sliderWidth = coordinates.size.width + LaunchedEffect(settledX, isDragging) { + if (!isDragging) { + knobPosition.snapTo(settledX) } - ) { + } + Column(modifier = Modifier.fillMaxWidth()) { Box( modifier = Modifier .fillMaxWidth() .height(KNOB_SIZE_DP.dp) ) { - // Track and step markers Canvas( modifier = Modifier .fillMaxWidth() .height(KNOB_SIZE_DP.dp) - .pointerInput(Unit) { + .pointerInput(stepPositions, steps) { detectTapGestures { offset -> val (closestStep, closestIndex) = findClosestStep(offset.x) coroutineScope.launch { + isDragging = true + knobPosition.snapTo(knobX) knobPosition.animateTo( targetValue = closestStep, animationSpec = SpringSpec(dampingRatio = 0.8f, stiffness = 400f), ) + isDragging = false } onValueChange(steps[closestIndex]) } @@ -145,7 +141,6 @@ fun StepSlider( 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), @@ -153,18 +148,15 @@ fun StepSlider( cornerRadius = CornerRadius(cornerRadius), ) - // Draw active track - val activeWidth = knobPosition.value - if (activeWidth > 0) { + if (knobX > 0f) { drawRoundRect( color = Colors.Green, topLeft = Offset(0f, trackY - trackHeight / 2), - size = Size(activeWidth, trackHeight), + size = Size(knobX, trackHeight), cornerRadius = CornerRadius(cornerRadius), ) } - // 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() } @@ -179,20 +171,20 @@ fun StepSlider( } } - // Knob Box( modifier = Modifier .offset { IntOffset( - x = (knobPosition.value - with(density) { KNOB_SIZE_DP.dp.toPx() / 2 }).roundToInt(), + x = (knobX - with(density) { KNOB_SIZE_DP.dp.toPx() / 2 }).roundToInt(), y = 0, ) } .size(KNOB_SIZE_DP.dp) - .pointerInput(Unit) { + .pointerInput(stepPositions, steps, sliderWidth) { detectDragGestures( - onDragStart = { _ -> - // No action needed on drag start + onDragStart = { + isDragging = true + coroutineScope.launch { knobPosition.snapTo(settledX) } }, onDragEnd = { val (closestStep, closestIndex) = findClosestStep(knobPosition.value) @@ -201,26 +193,25 @@ fun StepSlider( targetValue = closestStep, animationSpec = SpringSpec(dampingRatio = 0.8f, stiffness = 400f), ) + isDragging = false } onValueChange(steps[closestIndex]) }, ) { _, dragAmount -> coroutineScope.launch { val newPosition = (knobPosition.value + dragAmount.x) - .coerceIn(0f, sliderWidth.toFloat()) + .coerceIn(0f, sliderWidth) knobPosition.snapTo(newPosition) } } } ) { - // 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) @@ -232,32 +223,49 @@ fun StepSlider( } } - // Labels participate in layout height (horizontal offset only) - Box( + StepSliderLabels( + steps = steps, + formatLabel = formatLabel, modifier = Modifier .fillMaxWidth() - .padding(top = 4.dp) - ) { - steps.forEachIndexed { index, step -> - Caption13Up( - text = formatLabel(step), - color = Colors.White64, - textAlign = TextAlign.Center, - modifier = Modifier - .width(KNOB_SIZE_DP.dp) - .offset { - val x = if (index < stepPositions.size) { - ( - stepPositions[index] - - with(density) { KNOB_SIZE_DP.dp.toPx() / 2 } - ).roundToInt() - } else { - 0 - } - IntOffset(x = x, y = 0) - } - ) - } + .padding(top = LABEL_TOP_PADDING_DP.dp) + ) + } + } +} + +@Composable +private fun StepSliderLabels( + steps: ImmutableList, + formatLabel: (Int) -> String, + modifier: Modifier = Modifier, +) { + Layout( + modifier = modifier, + content = { + steps.forEach { step -> + Caption13Up( + text = formatLabel(step), + color = Colors.White64, + textAlign = TextAlign.Center, + 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) } } } From ef76b51b78ccbeac135dbedcba111df59230d0d3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 15 Aug 2026 18:33:14 +0200 Subject: [PATCH 07/71] refactor: measure slider as one unit --- .../java/to/bitkit/ui/components/Slider.kt | 97 +++++++++++++------ 1 file changed, 65 insertions(+), 32 deletions(-) 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 f1d40fc85..24fb60d48 100644 --- a/app/src/main/java/to/bitkit/ui/components/Slider.kt +++ b/app/src/main/java/to/bitkit/ui/components/Slider.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -34,7 +33,9 @@ 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.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -60,7 +61,7 @@ private const val LABEL_TOP_PADDING_DP = 4 @Suppress("CyclomaticComplexMethod") @Composable -fun StepSlider( +fun Slider( value: Int, steps: ImmutableList, onValueChange: (Int) -> Unit, @@ -71,20 +72,42 @@ fun StepSlider( val coroutineScope = rememberCoroutineScope() 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() } - BoxWithConstraints(modifier = modifier.fillMaxWidth()) { - val sliderWidth = constraints.maxWidth.toFloat() - val stepPositions = remember(steps, sliderWidth) { - if (sliderWidth <= 0f) { - emptyList() - } else { - val numSteps = (steps.size - 1).coerceAtLeast(1) - steps.indices.map { index -> (index.toFloat() / numSteps) * sliderWidth } - } + val compositionStepPositions = remember(steps, layoutWidthPx) { + val sliderWidth = layoutWidthPx.toFloat() + if (sliderWidth <= 0f) { + emptyList() + } else { + 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 } + + LaunchedEffect(settledX, isDragging) { + if (!isDragging) { + knobPosition.snapTo(settledX) + } + } + + SubcomposeLayout( + modifier = modifier + .fillMaxWidth() + .onSizeChanged { layoutWidthPx = it.width } + ) { 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 valueIndex = steps.indexOf(value).takeIf { it >= 0 } ?: 0 - val settledX = stepPositions.getOrElse(valueIndex) { 0f } - val knobX = if (isDragging) knobPosition.value else settledX + val knobX = if (isDragging) knobPosition.value else stepPositions.getOrElse(valueIndex) { 0f } fun findClosestStep(currentPosition: Float): Pair { if (stepPositions.isEmpty()) return 0f to 0 @@ -105,13 +128,7 @@ fun StepSlider( return closestPosition to closestIndex } - LaunchedEffect(settledX, isDragging) { - if (!isDragging) { - knobPosition.snapTo(settledX) - } - } - - Column(modifier = Modifier.fillMaxWidth()) { + val trackPlaceable = subcompose(StepSliderSlot.Track) { Box( modifier = Modifier .fillMaxWidth() @@ -222,18 +239,25 @@ fun StepSlider( } } } + }.first().measure(Constraints.fixed(width, knobHeightPx)) + val labelsPlaceable = subcompose(StepSliderSlot.Labels) { StepSliderLabels( steps = steps, formatLabel = formatLabel, - modifier = Modifier - .fillMaxWidth() - .padding(top = LABEL_TOP_PADDING_DP.dp) ) + }.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) } } } +private enum class StepSliderSlot { Track, Labels } + @Composable private fun StepSliderLabels( steps: ImmutableList, @@ -272,7 +296,7 @@ private fun StepSliderLabels( } /** - * 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 @@ -393,7 +417,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 }, @@ -404,7 +428,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)) { @@ -420,13 +444,22 @@ private fun AmountSliderPreview() { @Preview @Composable -private fun Preview2() { +private fun PreviewVerticalStack() { AppThemeSurface { + var dollars by remember { mutableIntStateOf(1) } + var times by remember { mutableIntStateOf(1) } 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 = 50, + steps = persistentListOf(1, 3, 5, 10, 50), + onValueChange = { times = it }, + formatLabel = { "${it}×" }, ) } } From af4163d1fa9934ddd0dce9ea632e83297cf1231c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 15 Aug 2026 18:33:24 +0200 Subject: [PATCH 08/71] fix: spacing and copy --- .../bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt | 8 ++++---- app/src/main/res/values/strings.xml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) 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 23992e2fe..af3495200 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 @@ -22,7 +22,7 @@ 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.settings.SettingsSwitchRow import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon @@ -101,7 +101,7 @@ fun QuickPaySettingsScreenContent( Spacer(modifier = Modifier.height(16.dp)) - StepSlider( + Slider( value = quickPayAmount, steps = sliderSteps, onValueChange = onQuickPayAmountChange, @@ -115,7 +115,7 @@ fun QuickPaySettingsScreenContent( color = Colors.White64, ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(16.dp)) BodyM( text = stringResource(R.string.settings__quickpay__settings__daily_text) @@ -126,7 +126,7 @@ fun QuickPaySettingsScreenContent( Spacer(modifier = Modifier.height(16.dp)) - StepSlider( + Slider( value = quickPayDailyLimitMultiplier, steps = dailyLimitSteps, onValueChange = onQuickPayDailyLimitMultiplierChange, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3cf21d8f5..85b43c59c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -934,7 +934,7 @@ <accent>Frictionless</accent>\npayments QuickPay Daily QuickPay limit - Auto-pay up to ${limit} per day without confirmation or PIN ({multiplier}× your threshold). After that, payments open Confirm. + Auto-pay up to ${limit} per day without PIN ({multiplier}× your threshold). After that, payments open Confirm. Quickpay threshold * 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*. From 17f544ca8a6f205b0e76fb0c5dc8f7ba71daf97e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 15 Aug 2026 18:58:30 +0200 Subject: [PATCH 09/71] fix: snap slider tap from current value --- .../java/to/bitkit/ui/components/Slider.kt | 10 +++++--- .../quickPay/QuickPaySettingsScreen.kt | 23 ++++++++++--------- 2 files changed, 19 insertions(+), 14 deletions(-) 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 24fb60d48..607774d51 100644 --- a/app/src/main/java/to/bitkit/ui/components/Slider.kt +++ b/app/src/main/java/to/bitkit/ui/components/Slider.kt @@ -24,6 +24,7 @@ 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 @@ -87,6 +88,7 @@ fun Slider( } val valueIndex = steps.indexOf(value).takeIf { it >= 0 } ?: 0 val settledX = compositionStepPositions.getOrElse(valueIndex) { 0f } + val settledXState = rememberUpdatedState(settledX) LaunchedEffect(settledX, isDragging) { if (!isDragging) { @@ -142,8 +144,8 @@ fun Slider( detectTapGestures { offset -> val (closestStep, closestIndex) = findClosestStep(offset.x) coroutineScope.launch { + knobPosition.snapTo(settledXState.value) isDragging = true - knobPosition.snapTo(knobX) knobPosition.animateTo( targetValue = closestStep, animationSpec = SpringSpec(dampingRatio = 0.8f, stiffness = 400f), @@ -200,8 +202,10 @@ fun Slider( .pointerInput(stepPositions, steps, sliderWidth) { detectDragGestures( onDragStart = { - isDragging = true - coroutineScope.launch { knobPosition.snapTo(settledX) } + coroutineScope.launch { + knobPosition.snapTo(settledXState.value) + isDragging = true + } }, onDragEnd = { val (closestStep, closestIndex) = findClosestStep(knobPosition.value) 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 af3495200..53a4d4247 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,7 +2,6 @@ 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 @@ -22,7 +21,9 @@ 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.FillHeight 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 @@ -75,7 +76,7 @@ fun QuickPaySettingsScreenContent( Column( modifier = Modifier.padding(horizontal = 16.dp) ) { - Spacer(modifier = Modifier.height(16.dp)) + VerticalSpacer(16.dp) SettingsSwitchRow( title = stringResource(R.string.settings__quickpay__settings__toggle), @@ -84,7 +85,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) @@ -92,14 +93,14 @@ 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) Slider( value = quickPayAmount, @@ -108,14 +109,14 @@ fun QuickPaySettingsScreenContent( modifier = Modifier.testTag("QuickpayAmountSlider") ) - Spacer(modifier = Modifier.height(32.dp)) + VerticalSpacer(32.dp) Caption13Up( text = stringResource(R.string.settings__quickpay__settings__daily_label), color = Colors.White64, ) - Spacer(modifier = Modifier.height(16.dp)) + VerticalSpacer(16.dp) BodyM( text = stringResource(R.string.settings__quickpay__settings__daily_text) @@ -124,7 +125,7 @@ fun QuickPaySettingsScreenContent( color = Colors.White64, ) - Spacer(modifier = Modifier.height(16.dp)) + VerticalSpacer(16.dp) Slider( value = quickPayDailyLimitMultiplier, @@ -134,7 +135,7 @@ fun QuickPaySettingsScreenContent( modifier = Modifier.testTag("QuickpayDailyLimitSlider") ) - Spacer(modifier = Modifier.weight(1f)) + FillHeight() Image( painter = painterResource(R.drawable.fast_forward), contentDescription = null, @@ -142,14 +143,14 @@ fun QuickPaySettingsScreenContent( .fillMaxWidth() .height(256.dp) ) - Spacer(modifier = Modifier.weight(1f)) + FillHeight() BodyS( text = stringResource(R.string.settings__quickpay__settings__note), color = Colors.White64, ) - Spacer(modifier = Modifier.height(16.dp)) + VerticalSpacer(16.dp) } } } From 7385c7355556c90a7ccee1712a6ca7552b9e5e8c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 15 Aug 2026 19:59:08 +0200 Subject: [PATCH 10/71] fix: simplify multiplier string templates --- app/src/main/java/to/bitkit/ui/components/Slider.kt | 2 +- .../to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 607774d51..0fe0bedb9 100644 --- a/app/src/main/java/to/bitkit/ui/components/Slider.kt +++ b/app/src/main/java/to/bitkit/ui/components/Slider.kt @@ -463,7 +463,7 @@ private fun PreviewVerticalStack() { value = 50, steps = persistentListOf(1, 3, 5, 10, 50), onValueChange = { times = it }, - formatLabel = { "${it}×" }, + formatLabel = { "$it×" }, ) } } 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 53a4d4247..a309c85e6 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 @@ -131,7 +131,7 @@ fun QuickPaySettingsScreenContent( value = quickPayDailyLimitMultiplier, steps = dailyLimitSteps, onValueChange = onQuickPayDailyLimitMultiplierChange, - formatLabel = { "${it}×" }, + formatLabel = { "$it×" }, modifier = Modifier.testTag("QuickpayDailyLimitSlider") ) From af959c93662aafa6936534bec8440b644135ae4c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 18 Aug 2026 11:24:21 +0200 Subject: [PATCH 11/71] fix: harden quickpay daily spend limits --- .../quickPay/QuickPaySettingsScreenTest.kt | 5 +- .../main/java/to/bitkit/data/CacheStore.kt | 22 +++ .../java/to/bitkit/ui/components/Slider.kt | 36 +++++ .../quickPay/QuickPaySettingsScreen.kt | 11 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 33 ++-- .../to/bitkit/viewmodels/QuickPayViewModel.kt | 151 +++++++++++------- .../java/to/bitkit/data/CacheStoreTest.kt | 15 ++ 7 files changed, 202 insertions(+), 71 deletions(-) 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 1a179f2b0..cb3457e75 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 0a8fe8e5d..e6645c207 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -144,6 +144,28 @@ class CacheStore @Inject constructor( return if (data.quickPaySpendDayKey == dayKey) data.quickPaySpentUsdToday else 0.0 } + suspend fun tryReserveQuickPaySpendUsd(amountUsd: Double, dayKey: String, dailyCapUsd: Double): Boolean { + var reserved = false + store.updateData { + val spent = if (it.quickPaySpendDayKey == dayKey) it.quickPaySpentUsdToday else 0.0 + if (spent + amountUsd > dailyCapUsd) return@updateData it + reserved = true + if (it.quickPaySpendDayKey != dayKey) { + it.copy(quickPaySpendDayKey = dayKey, quickPaySpentUsdToday = amountUsd) + } else { + it.copy(quickPaySpentUsdToday = spent + amountUsd) + } + } + return reserved + } + + suspend fun releaseQuickPaySpendUsd(amountUsd: Double, dayKey: String) { + store.updateData { + if (it.quickPaySpendDayKey != dayKey) return@updateData it + it.copy(quickPaySpentUsdToday = (it.quickPaySpentUsdToday - amountUsd).coerceAtLeast(0.0)) + } + } + suspend fun recordQuickPaySpendUsd(amountUsd: Double, dayKey: String) { store.updateData { if (it.quickPaySpendDayKey != dayKey) { 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 0fe0bedb9..851ee7312 100644 --- a/app/src/main/java/to/bitkit/ui/components/Slider.kt +++ b/app/src/main/java/to/bitkit/ui/components/Slider.kt @@ -38,6 +38,11 @@ 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 @@ -100,6 +105,12 @@ fun Slider( modifier = modifier .fillMaxWidth() .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() @@ -218,6 +229,12 @@ fun Slider( } onValueChange(steps[closestIndex]) }, + onDragCancel = { + coroutineScope.launch { + knobPosition.snapTo(settledXState.value) + isDragging = false + } + }, ) { _, dragAmount -> coroutineScope.launch { val newPosition = (knobPosition.value + dragAmount.x) @@ -262,6 +279,25 @@ fun Slider( 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, 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 a309c85e6..0e7f94ca7 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 @@ -5,6 +5,8 @@ import androidx.compose.foundation.layout.Column 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 @@ -21,7 +23,6 @@ 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.FillHeight import to.bitkit.ui.components.Slider import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.components.settings.SettingsSwitchRow @@ -74,7 +75,9 @@ fun QuickPaySettingsScreenContent( ) Column( - modifier = Modifier.padding(horizontal = 16.dp) + modifier = Modifier + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()) ) { VerticalSpacer(16.dp) @@ -135,7 +138,7 @@ fun QuickPaySettingsScreenContent( modifier = Modifier.testTag("QuickpayDailyLimitSlider") ) - FillHeight() + VerticalSpacer(32.dp) Image( painter = painterResource(R.drawable.fast_forward), contentDescription = null, @@ -143,7 +146,7 @@ fun QuickPaySettingsScreenContent( .fillMaxWidth() .height(256.dp) ) - FillHeight() + VerticalSpacer(32.dp) BodyS( text = stringResource(R.string.settings__quickpay__settings__note), diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index fb54acf8b..beab88153 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -74,6 +74,7 @@ import org.lightningdevkit.ldknode.Txid import to.bitkit.BuildConfig import to.bitkit.R import to.bitkit.data.CacheStore +import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.data.resetPin @@ -2633,21 +2634,31 @@ class AppViewModel @Inject constructor( val settings = settingsStore.data.first() if (!settings.isQuickPayEnabled || amountSats == 0uL) return false + return isWithinQuickPayThreshold(amountSats, settings) && isWithinQuickPayDailyCap(amountSats, settings) + } + + private suspend fun isWithinQuickPayThreshold(amountSats: ULong, settings: SettingsData): Boolean { val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD").getOrNull() - val amountUsd = currencyRepo.convertSatsToFiat(amountSats.toLong(), "USD").getOrNull()?.value?.toDouble() - if (quickPayAmountSats == null || amountUsd == null || amountSats > quickPayAmountSats) return false + ?: return false + return amountSats <= quickPayAmountSats + } - val dailyCapUsd = settings.quickPayAmount.toDouble() * settings.quickPayDailyLimitMultiplier + private suspend fun isWithinQuickPayDailyCap(amountSats: ULong, settings: SettingsData): Boolean { + val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD").getOrNull() + ?: return false + val dailyCapSats = quickPayAmountSats * settings.quickPayDailyLimitMultiplier.toULong() + val dailyCapUsd = currencyRepo.convertSatsToFiat(dailyCapSats.toLong(), "USD").getOrNull()?.value?.toDouble() + ?: return false + val amountUsd = currencyRepo.convertSatsToFiat(amountSats.toLong(), "USD").getOrNull()?.value?.toDouble() + ?: return false val spentUsdToday = cacheStore.quickPaySpentUsdForDay(quickPaySpendDayKey()) - if (spentUsdToday + amountUsd > dailyCapUsd) { - Logger.info( - "Skipping QuickPay: daily spend '$spentUsdToday' + '$amountUsd' exceeds cap '$dailyCapUsd'", - context = TAG, - ) - return false - } + if (spentUsdToday + amountUsd <= dailyCapUsd) return true - return true + Logger.info( + "Skipping QuickPay: daily spend '$spentUsdToday' + '$amountUsd' exceeds cap '$dailyCapUsd'", + context = TAG, + ) + return false } private fun resetAmountInput() { diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index e4fb98fa0..136bc941b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -7,11 +7,13 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.PaymentId import to.bitkit.data.CacheStore +import to.bitkit.data.SettingsStore import to.bitkit.ext.WatchResult import to.bitkit.ext.callbackAmountMsats import to.bitkit.ext.quickPaySpendDayKey @@ -32,6 +34,7 @@ class QuickPayViewModel @Inject constructor( private val pendingPaymentRepo: PendingPaymentRepo, private val currencyRepo: CurrencyRepo, private val cacheStore: CacheStore, + private val settingsStore: SettingsStore, ) : ViewModel() { companion object { @@ -45,68 +48,99 @@ class QuickPayViewModel @Inject constructor( fun pay(data: QuickPayData) { viewModelScope.launch { - val (bolt11, amount, displaySats) = when (data) { - is QuickPayData.Bolt11 -> { - Logger.info("QuickPay: processing bolt11 invoice") - Triple(data.bolt11, null, data.sats) - } + val prepared = preparePayment(data) ?: return@launch + val dayKey = quickPaySpendDayKey() + if (!reserveSpend(prepared.amountUsd, dayKey)) return@launch - is QuickPayData.LnurlPay -> { - Logger.info("QuickPay: fetching LNURL Pay invoice from callback") - val invoice = lightningRepo.fetchLnurlInvoice( - data = data.data, - amountMsats = data.data.callbackAmountMsats(data.sats), - ) - .getOrElse { error -> - _uiState.update { - it.copy(result = QuickPayResult.Error(error.message.orEmpty())) - } - return@launch - } - Triple(invoice.bolt11, null, data.sats) - } + sendLightning(prepared.bolt11, prepared.amount) + .onSuccess { onPaymentSuccess(it, prepared.displaySats) } + .onFailure { onPaymentFailure(it, prepared.displaySats, prepared.amountUsd, dayKey) } + } + } + + private suspend fun preparePayment(data: QuickPayData): PreparedQuickPay? { + val (bolt11, amount, displaySats) = when (data) { + is QuickPayData.Bolt11 -> { + Logger.info("QuickPay: processing bolt11 invoice") + Triple(data.bolt11, null, data.sats) } - sendLightning(bolt11, amount) - .onSuccess { paymentHash -> - Logger.info("QuickPay lightning payment successful") - recordQuickPaySpend(displaySats) - _uiState.update { - it.copy( - result = QuickPayResult.Success( - paymentHash = paymentHash, - amountWithFee = 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) - recordQuickPaySpend(displaySats) - _uiState.update { - it.copy( - result = QuickPayResult.Pending( - paymentHash = error.paymentHash, - amount = displaySats.toLong(), - ) - ) - } - return@onFailure - } - Logger.error("QuickPay lightning payment failed", error, context = TAG) - - _uiState.update { - it.copy(result = QuickPayResult.Error(error.message.orEmpty())) - } + is QuickPayData.LnurlPay -> { + Logger.info("QuickPay: fetching LNURL Pay invoice from callback") + val invoice = lightningRepo.fetchLnurlInvoice( + data = data.data, + amountMsats = data.data.callbackAmountMsats(data.sats), + ).getOrElse { error -> + _uiState.update { it.copy(result = QuickPayResult.Error(error.message.orEmpty())) } + return null } + Triple(invoice.bolt11, null, data.sats) + } + } + val amountUsd = currencyRepo.convertSatsToFiat(displaySats.toLong(), "USD").getOrNull()?.value?.toDouble() + if (amountUsd == null) { + _uiState.update { it.copy(result = QuickPayResult.Error("Currency conversion failed")) } + return null + } + return PreparedQuickPay(bolt11, amount, displaySats, amountUsd) + } + + private suspend fun reserveSpend(amountUsd: Double, dayKey: String): Boolean { + val dailyCapUsd = resolveDailyCapUsd() + if (dailyCapUsd == null) { + _uiState.update { it.copy(result = QuickPayResult.Error("Currency conversion failed")) } + return false + } + val reserved = cacheStore.tryReserveQuickPaySpendUsd(amountUsd, dayKey, dailyCapUsd) + if (!reserved) { + Logger.info("Skipping QuickPay pay: daily spend reserve failed for '$amountUsd'", context = TAG) + _uiState.update { it.copy(result = QuickPayResult.Error("Daily QuickPay limit reached")) } } + return reserved } - private suspend fun recordQuickPaySpend(amountSats: ULong) { - val amountUsd = currencyRepo.convertSatsToFiat(amountSats.toLong(), "USD").getOrNull()?.value?.toDouble() - ?: return - cacheStore.recordQuickPaySpendUsd(amountUsd, quickPaySpendDayKey()) + private fun onPaymentSuccess(paymentHash: String, displaySats: ULong) { + Logger.info("QuickPay lightning payment successful") + _uiState.update { + it.copy( + result = QuickPayResult.Success( + paymentHash = paymentHash, + amountWithFee = displaySats.toLong() // TODO GET FEE WHEN AVAILABLE + ) + ) + } + } + + private suspend fun onPaymentFailure( + error: Throwable, + displaySats: ULong, + amountUsd: Double, + dayKey: String, + ) { + 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 = displaySats.toLong(), + ) + ) + } + return + } + Logger.error("QuickPay lightning payment failed", error, context = TAG) + cacheStore.releaseQuickPaySpendUsd(amountUsd, dayKey) + _uiState.update { it.copy(result = QuickPayResult.Error(error.message.orEmpty())) } + } + + private suspend fun resolveDailyCapUsd(): Double? { + val settings = settingsStore.data.first() + val thresholdSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD").getOrNull() + ?: return null + val dailyCapSats = thresholdSats * settings.quickPayDailyLimitMultiplier.toULong() + return currencyRepo.convertSatsToFiat(dailyCapSats.toLong(), "USD").getOrNull()?.value?.toDouble() } private suspend fun sendLightning( @@ -134,6 +168,13 @@ class QuickPayViewModel @Inject constructor( } } +private data class PreparedQuickPay( + val bolt11: String, + val amount: ULong?, + val displaySats: ULong, + val amountUsd: Double, +) + sealed class QuickPayResult { data class Success( val paymentHash: String, diff --git a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt index 90855eb81..c595d7f47 100644 --- a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt +++ b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt @@ -116,4 +116,19 @@ class CacheStoreTest : BaseUnitTest() { assertEquals(4.0, sut.quickPaySpentUsdForDay("2026-08-16")) assertEquals(0.0, sut.quickPaySpentUsdForDay("2026-08-15")) } + + @Test + fun `tryReserveQuickPaySpendUsd reserves under the cap and rejects over it`() = test { + assertTrue(sut.tryReserveQuickPaySpendUsd(amountUsd = 10.0, dayKey = "2026-08-15", dailyCapUsd = 25.0)) + assertTrue(sut.tryReserveQuickPaySpendUsd(amountUsd = 10.0, dayKey = "2026-08-15", dailyCapUsd = 25.0)) + assertFalse(sut.tryReserveQuickPaySpendUsd(amountUsd = 10.0, dayKey = "2026-08-15", dailyCapUsd = 25.0)) + assertEquals(20.0, sut.quickPaySpentUsdForDay("2026-08-15")) + } + + @Test + fun `releaseQuickPaySpendUsd rolls back a reservation`() = test { + assertTrue(sut.tryReserveQuickPaySpendUsd(amountUsd = 5.0, dayKey = "2026-08-15", dailyCapUsd = 25.0)) + sut.releaseQuickPaySpendUsd(amountUsd = 5.0, dayKey = "2026-08-15") + assertEquals(0.0, sut.quickPaySpentUsdForDay("2026-08-15")) + } } From 7cdae86a21edc7ea3bf2ca636d1bb32689be963d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 18 Aug 2026 13:34:36 +0200 Subject: [PATCH 12/71] fix: localize error messages and texts --- .../quickPay/QuickPaySettingsScreen.kt | 3 ++- .../to/bitkit/viewmodels/QuickPayViewModel.kt | 25 +++++++++++++++---- app/src/main/res/values/strings.xml | 3 +++ 3 files changed, 25 insertions(+), 6 deletions(-) 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 0e7f94ca7..ea30a78d9 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 @@ -66,6 +66,7 @@ fun QuickPaySettingsScreenContent( 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( @@ -134,7 +135,7 @@ fun QuickPaySettingsScreenContent( value = quickPayDailyLimitMultiplier, steps = dailyLimitSteps, onValueChange = onQuickPayDailyLimitMultiplierChange, - formatLabel = { "$it×" }, + formatLabel = { multiplierFormat.replace("{multiplier}", it.toString()) }, modifier = Modifier.testTag("QuickpayDailyLimitSlider") ) diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index 136bc941b..ae9e05a07 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.PaymentId +import to.bitkit.R import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.ext.WatchResult @@ -71,7 +72,7 @@ class QuickPayViewModel @Inject constructor( data = data.data, amountMsats = data.data.callbackAmountMsats(data.sats), ).getOrElse { error -> - _uiState.update { it.copy(result = QuickPayResult.Error(error.message.orEmpty())) } + setError(error) return null } Triple(invoice.bolt11, null, data.sats) @@ -79,7 +80,7 @@ class QuickPayViewModel @Inject constructor( } val amountUsd = currencyRepo.convertSatsToFiat(displaySats.toLong(), "USD").getOrNull()?.value?.toDouble() if (amountUsd == null) { - _uiState.update { it.copy(result = QuickPayResult.Error("Currency conversion failed")) } + setError(QuickPayCurrencyConversionError()) return null } return PreparedQuickPay(bolt11, amount, displaySats, amountUsd) @@ -88,13 +89,13 @@ class QuickPayViewModel @Inject constructor( private suspend fun reserveSpend(amountUsd: Double, dayKey: String): Boolean { val dailyCapUsd = resolveDailyCapUsd() if (dailyCapUsd == null) { - _uiState.update { it.copy(result = QuickPayResult.Error("Currency conversion failed")) } + setError(QuickPayCurrencyConversionError()) return false } val reserved = cacheStore.tryReserveQuickPaySpendUsd(amountUsd, dayKey, dailyCapUsd) if (!reserved) { Logger.info("Skipping QuickPay pay: daily spend reserve failed for '$amountUsd'", context = TAG) - _uiState.update { it.copy(result = QuickPayResult.Error("Daily QuickPay limit reached")) } + setError(QuickPayDailyLimitReachedError()) } return reserved } @@ -132,7 +133,17 @@ class QuickPayViewModel @Inject constructor( } Logger.error("QuickPay lightning payment failed", error, context = TAG) cacheStore.releaseQuickPaySpendUsd(amountUsd, dayKey) - _uiState.update { it.copy(result = QuickPayResult.Error(error.message.orEmpty())) } + setError(error) + } + + private fun setError(error: Throwable) { + _uiState.update { it.copy(result = QuickPayResult.Error(errorMessage(error))) } + } + + private fun errorMessage(error: Throwable): String = when (error) { + is QuickPayCurrencyConversionError -> context.getString(R.string.wallet__send_quickpay__currency_conversion) + is QuickPayDailyLimitReachedError -> context.getString(R.string.wallet__send_quickpay__daily_limit) + else -> error.message?.takeIf { it.isNotBlank() } ?: context.getString(R.string.common__error_body) } private suspend fun resolveDailyCapUsd(): Double? { @@ -175,6 +186,10 @@ private data class PreparedQuickPay( val amountUsd: Double, ) +private class QuickPayCurrencyConversionError : AppError("Currency conversion failed") + +private class QuickPayDailyLimitReachedError : AppError("Daily QuickPay limit reached") + sealed class QuickPayResult { data class Success( val paymentHash: String, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 85b43c59c..a93e46970 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -936,6 +936,7 @@ 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 @@ -1239,6 +1240,8 @@ Reserve Balance This payment is taking a bit longer than expected. You can continue using Bitkit. Payment Pending + Currency conversion failed + Daily QuickPay limit reached QuickPay Paying\n<accent>invoice...</accent> Confirm From 3593678a85f384c7603eef8ed93e62094b8b061e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 18 Aug 2026 13:34:36 +0200 Subject: [PATCH 13/71] refactor: use USD constant --- .../main/java/to/bitkit/viewmodels/AppViewModel.kt | 13 +++++++------ .../java/to/bitkit/viewmodels/QuickPayViewModel.kt | 7 ++++--- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index beab88153..653c73fcd 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -124,6 +124,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 @@ -2638,18 +2639,18 @@ class AppViewModel @Inject constructor( } private suspend fun isWithinQuickPayThreshold(amountSats: ULong, settings: SettingsData): Boolean { - val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD").getOrNull() + val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), USD).getOrNull() ?: return false return amountSats <= quickPayAmountSats } private suspend fun isWithinQuickPayDailyCap(amountSats: ULong, settings: SettingsData): Boolean { - val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD").getOrNull() + val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), USD).getOrNull() ?: return false val dailyCapSats = quickPayAmountSats * settings.quickPayDailyLimitMultiplier.toULong() - val dailyCapUsd = currencyRepo.convertSatsToFiat(dailyCapSats.toLong(), "USD").getOrNull()?.value?.toDouble() + val dailyCapUsd = currencyRepo.convertSatsToFiat(dailyCapSats.toLong(), USD).getOrNull()?.value?.toDouble() ?: return false - val amountUsd = currencyRepo.convertSatsToFiat(amountSats.toLong(), "USD").getOrNull()?.value?.toDouble() + val amountUsd = currencyRepo.convertSatsToFiat(amountSats.toLong(), USD).getOrNull()?.value?.toDouble() ?: return false val spentUsdToday = cacheStore.quickPaySpentUsdForDay(quickPaySpendDayKey()) if (spentUsdToday + amountUsd <= dailyCapUsd) return true @@ -2702,7 +2703,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 && @@ -2735,7 +2736,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 diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index ae9e05a07..7ee91cd50 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -20,6 +20,7 @@ import to.bitkit.ext.callbackAmountMsats import to.bitkit.ext.quickPaySpendDayKey import to.bitkit.ext.toUserMessage import to.bitkit.ext.watchUntil +import to.bitkit.models.USD import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.PaymentPendingException @@ -78,7 +79,7 @@ class QuickPayViewModel @Inject constructor( Triple(invoice.bolt11, null, data.sats) } } - val amountUsd = currencyRepo.convertSatsToFiat(displaySats.toLong(), "USD").getOrNull()?.value?.toDouble() + val amountUsd = currencyRepo.convertSatsToFiat(displaySats.toLong(), USD).getOrNull()?.value?.toDouble() if (amountUsd == null) { setError(QuickPayCurrencyConversionError()) return null @@ -148,10 +149,10 @@ class QuickPayViewModel @Inject constructor( private suspend fun resolveDailyCapUsd(): Double? { val settings = settingsStore.data.first() - val thresholdSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD").getOrNull() + val thresholdSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), USD).getOrNull() ?: return null val dailyCapSats = thresholdSats * settings.quickPayDailyLimitMultiplier.toULong() - return currencyRepo.convertSatsToFiat(dailyCapSats.toLong(), "USD").getOrNull()?.value?.toDouble() + return currencyRepo.convertSatsToFiat(dailyCapSats.toLong(), USD).getOrNull()?.value?.toDouble() } private suspend fun sendLightning( From 4761c75428fed94aec570aac2e6028ab452fc6ef Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 18 Aug 2026 13:34:36 +0200 Subject: [PATCH 14/71] feat: use quickpay routing fee on ui --- .../to/bitkit/viewmodels/QuickPayViewModel.kt | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index 7ee91cd50..3fb41e0ae 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -21,6 +21,8 @@ import to.bitkit.ext.quickPaySpendDayKey import to.bitkit.ext.toUserMessage import to.bitkit.ext.watchUntil import to.bitkit.models.USD +import to.bitkit.models.msatFloorOf +import to.bitkit.models.safe import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.PaymentPendingException @@ -55,7 +57,7 @@ class QuickPayViewModel @Inject constructor( if (!reserveSpend(prepared.amountUsd, dayKey)) return@launch sendLightning(prepared.bolt11, prepared.amount) - .onSuccess { onPaymentSuccess(it, prepared.displaySats) } + .onSuccess { onPaymentSuccess(it.paymentHash, prepared.displaySats, it.feePaidSats) } .onFailure { onPaymentFailure(it, prepared.displaySats, prepared.amountUsd, dayKey) } } } @@ -101,13 +103,13 @@ class QuickPayViewModel @Inject constructor( return reserved } - private fun onPaymentSuccess(paymentHash: String, displaySats: ULong) { + private fun onPaymentSuccess(paymentHash: String, displaySats: ULong, feePaidSats: ULong) { Logger.info("QuickPay lightning payment successful") _uiState.update { it.copy( result = QuickPayResult.Success( paymentHash = paymentHash, - amountWithFee = displaySats.toLong() // TODO GET FEE WHEN AVAILABLE + amountWithFee = (displaySats.safe() + feePaidSats.safe()).toLong(), ) ) } @@ -158,7 +160,7 @@ class QuickPayViewModel @Inject constructor( private suspend fun sendLightning( bolt11: String, amount: ULong? = null, - ): Result { + ): Result { val hash = lightningRepo.payInvoice(bolt11 = bolt11, sats = amount) .onFailure { exception -> return Result.failure(exception) @@ -168,7 +170,15 @@ class QuickPayViewModel @Inject constructor( // 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.PaymentSuccessful if it.paymentHash == hash -> WatchResult.Complete( + Result.success( + SettledQuickPayPayment( + paymentHash = hash, + feePaidSats = msatFloorOf(it.feePaidMsat ?: 0u), + ) + ) + ) + is Event.PaymentFailed if it.paymentHash == hash -> WatchResult.Complete( Result.failure(AppError(it.reason.toUserMessage(context))) ) @@ -187,6 +197,11 @@ private data class PreparedQuickPay( val amountUsd: Double, ) +private data class SettledQuickPayPayment( + val paymentHash: PaymentId, + val feePaidSats: ULong, +) + private class QuickPayCurrencyConversionError : AppError("Currency conversion failed") private class QuickPayDailyLimitReachedError : AppError("Daily QuickPay limit reached") From 07a88a2b742c26749f858e7e96f7a0af44c381a3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 18 Aug 2026 20:42:42 +0200 Subject: [PATCH 15/71] fix: harden quickpay spend and slider --- .../main/java/to/bitkit/data/CacheStore.kt | 90 ++++++++++++++----- .../bitkit/repositories/PendingPaymentRepo.kt | 6 +- .../java/to/bitkit/ui/components/Slider.kt | 73 ++++++++++----- .../screens/wallets/send/SendPendingScreen.kt | 7 +- .../java/to/bitkit/ui/sheets/SendSheet.kt | 4 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 25 ++++-- .../to/bitkit/viewmodels/QuickPayViewModel.kt | 39 ++++---- .../java/to/bitkit/data/CacheStoreTest.kt | 76 +++++++++++----- .../ui/components/StepSliderMappingTest.kt | 25 ++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 14 ++- 10 files changed, 256 insertions(+), 103 deletions(-) create mode 100644 app/src/test/java/to/bitkit/ui/components/StepSliderMappingTest.kt diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index e6645c207..e97ade4ba 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -139,40 +139,72 @@ class CacheStore @Inject constructor( store.updateData { it.copy(backgroundReceive = null) } } - suspend fun quickPaySpentUsdForDay(dayKey: String): Double { + suspend fun quickPaySpentSatsForDay(dayKey: String): Long { val data = store.data.first() - return if (data.quickPaySpendDayKey == dayKey) data.quickPaySpentUsdToday else 0.0 + return data.quickPaySpendFor(dayKey).spentSats } - suspend fun tryReserveQuickPaySpendUsd(amountUsd: Double, dayKey: String, dailyCapUsd: Double): Boolean { + suspend fun tryReserveQuickPaySpendSats(amountSats: Long, dayKey: String, dailyCapSats: Long): Boolean { var reserved = false store.updateData { - val spent = if (it.quickPaySpendDayKey == dayKey) it.quickPaySpentUsdToday else 0.0 - if (spent + amountUsd > dailyCapUsd) return@updateData it + val spend = it.quickPaySpendFor(dayKey) + if (spend.spentSats + amountSats > dailyCapSats) return@updateData it reserved = true - if (it.quickPaySpendDayKey != dayKey) { - it.copy(quickPaySpendDayKey = dayKey, quickPaySpentUsdToday = amountUsd) - } else { - it.copy(quickPaySpentUsdToday = spent + amountUsd) - } + it.copy( + quickPaySpendDayKey = spend.dayKey, + quickPaySpentSatsToday = spend.spentSats + amountSats, + ) } return reserved } - suspend fun releaseQuickPaySpendUsd(amountUsd: Double, dayKey: String) { + suspend fun releaseQuickPaySpendSats(amountSats: Long, dayKey: String) { store.updateData { - if (it.quickPaySpendDayKey != dayKey) return@updateData it - it.copy(quickPaySpentUsdToday = (it.quickPaySpentUsdToday - amountUsd).coerceAtLeast(0.0)) + val spend = it.quickPaySpendFor(dayKey) + if (spend.dayKey != it.quickPaySpendDayKey) return@updateData it + it.copy(quickPaySpentSatsToday = (spend.spentSats - amountSats).coerceAtLeast(0L)) } } - suspend fun recordQuickPaySpendUsd(amountUsd: Double, dayKey: String) { + suspend fun recordQuickPaySpendSats(amountSats: Long, dayKey: String) { store.updateData { - if (it.quickPaySpendDayKey != dayKey) { - it.copy(quickPaySpendDayKey = dayKey, quickPaySpentUsdToday = amountUsd) - } else { - it.copy(quickPaySpentUsdToday = it.quickPaySpentUsdToday + amountUsd) - } + val spend = it.quickPaySpendFor(dayKey) + it.copy( + quickPaySpendDayKey = spend.dayKey, + quickPaySpentSatsToday = spend.spentSats + amountSats, + ) + } + } + + suspend fun rememberQuickPayReservation(paymentHash: String, amountSats: Long, dayKey: String) { + if (paymentHash.isBlank()) return + store.updateData { + it.copy( + quickPayReservations = it.quickPayReservations + ( + paymentHash to QuickPaySpendReservation(amountSats = amountSats, dayKey = dayKey) + ), + ) + } + } + + suspend fun releaseQuickPayReservation(paymentHash: String) { + if (paymentHash.isBlank()) return + store.updateData { data -> + val reservation = data.quickPayReservations[paymentHash] ?: return@updateData data + val spend = data.quickPaySpendFor(reservation.dayKey) + data.copy( + quickPaySpendDayKey = spend.dayKey, + quickPaySpentSatsToday = (spend.spentSats - reservation.amountSats).coerceAtLeast(0L), + quickPayReservations = data.quickPayReservations - paymentHash, + ) + } + } + + suspend fun clearQuickPayReservation(paymentHash: String) { + if (paymentHash.isBlank()) return + store.updateData { + if (paymentHash !in it.quickPayReservations) return@updateData it + it.copy(quickPayReservations = it.quickPayReservations - paymentHash) } } @@ -202,7 +234,8 @@ data class AppCacheData( val addressSearchLastUsedReceiveIndexes: Map = mapOf(), val addressSearchLastUsedChangeIndexes: Map = mapOf(), val quickPaySpendDayKey: String = "", - val quickPaySpentUsdToday: Double = 0.0, + val quickPaySpentSatsToday: Long = 0L, + val quickPayReservations: Map = emptyMap(), ) { fun isActivityDeleted(activityId: String, walletId: String): Boolean = scopedActivityId(walletId, activityId) in deletedActivities || @@ -213,4 +246,21 @@ data class AppCacheData( fun invalidateReceiveLightningInvoice() = copy(bip21 = "", bolt11 = "", bolt11PaymentHash = "") fun invalidateReceiveOnchainAddress() = copy(bip21 = "", onchainAddress = "") + + fun quickPaySpendFor(dayKey: String): QuickPayDaySpend = when { + quickPaySpendDayKey.isEmpty() || dayKey > quickPaySpendDayKey -> QuickPayDaySpend(dayKey, 0L) + dayKey == quickPaySpendDayKey -> QuickPayDaySpend(dayKey, quickPaySpentSatsToday) + else -> QuickPayDaySpend(quickPaySpendDayKey, quickPaySpentSatsToday) + } } + +@Serializable +data class QuickPaySpendReservation( + val amountSats: Long, + val dayKey: String, +) + +data class QuickPayDaySpend( + val dayKey: String, + val spentSats: Long, +) diff --git a/app/src/main/java/to/bitkit/repositories/PendingPaymentRepo.kt b/app/src/main/java/to/bitkit/repositories/PendingPaymentRepo.kt index 007c7b2f7..585e74885 100644 --- a/app/src/main/java/to/bitkit/repositories/PendingPaymentRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PendingPaymentRepo.kt @@ -47,7 +47,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) : PendingPaymentResolution } 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 851ee7312..33f797d9b 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 @@ -38,6 +38,7 @@ 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.platform.LocalLayoutDirection import androidx.compose.ui.semantics.ProgressBarRangeInfo import androidx.compose.ui.semantics.progressBarRangeInfo import androidx.compose.ui.semantics.semantics @@ -47,6 +48,7 @@ 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.LayoutDirection import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -58,6 +60,15 @@ import kotlin.math.roundToInt private const val KNOB_SIZE_DP = 32 +internal fun sliderPointerToLogical(x: Float, width: Float, isRtl: Boolean): Float = + if (isRtl) width - x else x + +internal fun sliderLogicalToVisual(x: Float, width: Float, isRtl: Boolean): Float = + if (isRtl) width - x else x + +internal fun sliderDragDeltaToLogical(deltaX: Float, isRtl: Boolean): Float = + if (isRtl) -deltaX else deltaX + /** Horizontal inset so the knob stays clear of the screen edge and its system back-gesture zone. */ private const val SLIDER_EDGE_INSET_DP = 16 private const val TRACK_HEIGHT_DP = 8 @@ -75,6 +86,7 @@ fun Slider( formatLabel: (Int) -> String = { "$$it" }, ) { val density = LocalDensity.current + val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl val coroutineScope = rememberCoroutineScope() val knobPosition = remember { Animatable(0f) } var isDragging by remember { mutableStateOf(false) } @@ -121,6 +133,7 @@ fun Slider( steps.indices.map { index -> (index.toFloat() / numSteps) * sliderWidth } } val knobX = if (isDragging) knobPosition.value else stepPositions.getOrElse(valueIndex) { 0f } + val visualKnobX = sliderLogicalToVisual(knobX, sliderWidth, isRtl) fun findClosestStep(currentPosition: Float): Pair { if (stepPositions.isEmpty()) return 0f to 0 @@ -151,9 +164,10 @@ fun Slider( modifier = Modifier .fillMaxWidth() .height(KNOB_SIZE_DP.dp) - .pointerInput(stepPositions, steps) { + .pointerInput(stepPositions, steps, isRtl, sliderWidth) { detectTapGestures { offset -> - val (closestStep, closestIndex) = findClosestStep(offset.x) + val logicalX = sliderPointerToLogical(offset.x, sliderWidth, isRtl) + val (closestStep, closestIndex) = findClosestStep(logicalX) coroutineScope.launch { knobPosition.snapTo(settledXState.value) isDragging = true @@ -179,10 +193,12 @@ fun Slider( ) if (knobX > 0f) { + val activeLeft = if (isRtl) visualKnobX else 0f + val activeWidth = if (isRtl) size.width - visualKnobX else visualKnobX drawRoundRect( color = Colors.Green, - topLeft = Offset(0f, trackY - trackHeight / 2), - size = Size(knobX, trackHeight), + topLeft = Offset(activeLeft, trackY - trackHeight / 2), + size = Size(activeWidth, trackHeight), cornerRadius = CornerRadius(cornerRadius), ) } @@ -192,9 +208,10 @@ fun Slider( val markerRadius = density.run { 2.5.dp.toPx() } stepPositions.forEach { position -> + val visualX = sliderLogicalToVisual(position, size.width, isRtl) drawRoundRect( color = Colors.White, - topLeft = Offset(position - markerWidth / 2, trackY - markerHeight / 2), + topLeft = Offset(visualX - markerWidth / 2, trackY - markerHeight / 2), size = Size(markerWidth, markerHeight), cornerRadius = CornerRadius(markerRadius), ) @@ -205,13 +222,13 @@ fun Slider( modifier = Modifier .offset { IntOffset( - x = (knobX - with(density) { KNOB_SIZE_DP.dp.toPx() / 2 }).roundToInt(), + x = (visualKnobX - with(density) { KNOB_SIZE_DP.dp.toPx() / 2 }).roundToInt(), y = 0, ) } .size(KNOB_SIZE_DP.dp) - .pointerInput(stepPositions, steps, sliderWidth) { - detectDragGestures( + .pointerInput(stepPositions, steps, sliderWidth, isRtl) { + detectHorizontalDragGestures( onDragStart = { coroutineScope.launch { knobPosition.snapTo(settledXState.value) @@ -237,8 +254,9 @@ fun Slider( }, ) { _, dragAmount -> coroutineScope.launch { - val newPosition = (knobPosition.value + dragAmount.x) - .coerceIn(0f, sliderWidth) + val newPosition = ( + knobPosition.value + sliderDragDeltaToLogical(dragAmount, isRtl) + ).coerceIn(0f, sliderWidth) knobPosition.snapTo(newPosition) } } @@ -348,6 +366,7 @@ fun AmountSlider( modifier: Modifier = Modifier, ) { val density = LocalDensity.current + val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl val coroutineScope = rememberCoroutineScope() var sliderWidth by remember { mutableIntStateOf(0) } @@ -356,9 +375,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) } @@ -369,6 +388,9 @@ fun AmountSlider( } } + val widthPx = sliderWidth.toFloat() + val visualKnobX = sliderLogicalToVisual(knobPosition.value, widthPx, isRtl) + Box( modifier = modifier .fillMaxWidth() @@ -381,10 +403,11 @@ fun AmountSlider( modifier = Modifier .fillMaxWidth() .height(KNOB_SIZE_DP.dp) - .pointerInput(sliderWidth, min, max) { + .pointerInput(sliderWidth, min, max, isRtl) { detectTapGestures { offset -> - val v = valueFor(offset.x) - coroutineScope.launch { knobPosition.snapTo(fractionFor(v) * sliderWidth) } + val logicalX = sliderPointerToLogical(offset.x, widthPx, isRtl) + val v = valueFor(logicalX) + coroutineScope.launch { knobPosition.snapTo(fractionFor(v) * widthPx) } onValueChange(v) } } @@ -401,11 +424,12 @@ fun AmountSlider( cornerRadius = CornerRadius(cornerRadius), ) // Active track - val activeWidth = knobPosition.value - if (activeWidth > 0) { + if (knobPosition.value > 0) { + val activeLeft = if (isRtl) visualKnobX else 0f + val activeWidth = if (isRtl) size.width - visualKnobX else visualKnobX drawRoundRect( color = Colors.Green, - topLeft = Offset(0f, trackY - trackHeight / 2), + topLeft = Offset(activeLeft, trackY - trackHeight / 2), size = Size(activeWidth, trackHeight), cornerRadius = CornerRadius(cornerRadius), ) @@ -417,16 +441,17 @@ fun AmountSlider( modifier = Modifier .offset { IntOffset( - x = (knobPosition.value - with(density) { KNOB_SIZE_DP.dp.toPx() / 2 }).roundToInt(), + x = (visualKnobX - with(density) { KNOB_SIZE_DP.dp.toPx() / 2 }).roundToInt(), y = 0, ) } .size(KNOB_SIZE_DP.dp) - .pointerInput(sliderWidth, min, max) { - detectDragGestures { _, dragAmount -> + .pointerInput(sliderWidth, min, max, isRtl) { + detectHorizontalDragGestures { _, dragAmount -> coroutineScope.launch { - val newPosition = (knobPosition.value + dragAmount.x) - .coerceIn(0f, sliderWidth.toFloat()) + val newPosition = ( + knobPosition.value + sliderDragDeltaToLogical(dragAmount, isRtl) + ).coerceIn(0f, widthPx) knobPosition.snapTo(newPosition) onValueChange(valueFor(newPosition)) } 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 ead96da93..ae02bb37a 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: () -> 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() } viewModel.onResolutionHandled() 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 5b801ee8a..0cff6a9f3 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -335,13 +335,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/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index a7bd91864..0385acec2 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1160,6 +1160,7 @@ class AppViewModel @Inject constructor( activityRepo.handlePaymentEvent(paymentHash) if (pendingPaymentRepo.isPending(paymentHash)) { clearPendingContactPaymentContext(paymentHash) + cacheStore.releaseQuickPayReservation(paymentHash) pendingPaymentRepo.resolve(PendingPaymentResolution.Failure(paymentHash)) if (_currentSheet.value !is Sheet.Send || !pendingPaymentRepo.isActive(paymentHash)) { notifyPendingPaymentFailed() @@ -1225,7 +1226,19 @@ class AppViewModel @Inject constructor( activityRepo.handlePaymentEvent(paymentHash) if (pendingPaymentRepo.isPending(paymentHash)) { syncContactForActivity(paymentHash) - pendingPaymentRepo.resolve(PendingPaymentResolution.Success(paymentHash)) + cacheStore.clearQuickPayReservation(paymentHash) + val amountWithFeeSats = activityRepo.findActivityByPaymentId( + paymentHashOrTxId = paymentHash, + type = ActivityFilter.LIGHTNING, + txType = PaymentType.SENT, + retry = true, + ).getOrNull()?.totalValue()?.toLong() + pendingPaymentRepo.resolve( + PendingPaymentResolution.Success( + paymentHash = paymentHash, + amountWithFeeSats = amountWithFeeSats, + ), + ) if (_currentSheet.value !is Sheet.Send || !pendingPaymentRepo.isActive(paymentHash)) { notifyPendingPaymentSucceeded() } @@ -2678,15 +2691,11 @@ class AppViewModel @Inject constructor( val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), USD).getOrNull() ?: return false val dailyCapSats = quickPayAmountSats * settings.quickPayDailyLimitMultiplier.toULong() - val dailyCapUsd = currencyRepo.convertSatsToFiat(dailyCapSats.toLong(), USD).getOrNull()?.value?.toDouble() - ?: return false - val amountUsd = currencyRepo.convertSatsToFiat(amountSats.toLong(), USD).getOrNull()?.value?.toDouble() - ?: return false - val spentUsdToday = cacheStore.quickPaySpentUsdForDay(quickPaySpendDayKey()) - if (spentUsdToday + amountUsd <= dailyCapUsd) return true + val spentSatsToday = cacheStore.quickPaySpentSatsForDay(quickPaySpendDayKey()).toULong() + if (spentSatsToday + amountSats <= dailyCapSats) return true Logger.info( - "Skipping QuickPay: daily spend '$spentUsdToday' + '$amountUsd' exceeds cap '$dailyCapUsd'", + "Skipping QuickPay: daily spend '$spentSatsToday' + '$amountSats' exceeds cap '$dailyCapSats'", context = TAG, ) return false diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index 3fb41e0ae..a3383fd37 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -54,11 +54,11 @@ class QuickPayViewModel @Inject constructor( viewModelScope.launch { val prepared = preparePayment(data) ?: return@launch val dayKey = quickPaySpendDayKey() - if (!reserveSpend(prepared.amountUsd, dayKey)) return@launch + if (!reserveSpend(prepared.displaySats, dayKey)) return@launch sendLightning(prepared.bolt11, prepared.amount) .onSuccess { onPaymentSuccess(it.paymentHash, prepared.displaySats, it.feePaidSats) } - .onFailure { onPaymentFailure(it, prepared.displaySats, prepared.amountUsd, dayKey) } + .onFailure { onPaymentFailure(it, prepared.displaySats, dayKey) } } } @@ -81,23 +81,22 @@ class QuickPayViewModel @Inject constructor( Triple(invoice.bolt11, null, data.sats) } } - val amountUsd = currencyRepo.convertSatsToFiat(displaySats.toLong(), USD).getOrNull()?.value?.toDouble() - if (amountUsd == null) { - setError(QuickPayCurrencyConversionError()) - return null - } - return PreparedQuickPay(bolt11, amount, displaySats, amountUsd) + return PreparedQuickPay(bolt11, amount, displaySats) } - private suspend fun reserveSpend(amountUsd: Double, dayKey: String): Boolean { - val dailyCapUsd = resolveDailyCapUsd() - if (dailyCapUsd == null) { + private suspend fun reserveSpend(amountSats: ULong, dayKey: String): Boolean { + val dailyCapSats = resolveDailyCapSats() + if (dailyCapSats == null) { setError(QuickPayCurrencyConversionError()) return false } - val reserved = cacheStore.tryReserveQuickPaySpendUsd(amountUsd, dayKey, dailyCapUsd) + val reserved = cacheStore.tryReserveQuickPaySpendSats( + amountSats = amountSats.toLong(), + dayKey = dayKey, + dailyCapSats = dailyCapSats.toLong(), + ) if (!reserved) { - Logger.info("Skipping QuickPay pay: daily spend reserve failed for '$amountUsd'", context = TAG) + Logger.info("Skipping QuickPay pay: daily spend reserve failed for '$amountSats'", context = TAG) setError(QuickPayDailyLimitReachedError()) } return reserved @@ -118,12 +117,16 @@ class QuickPayViewModel @Inject constructor( private suspend fun onPaymentFailure( error: Throwable, displaySats: ULong, - amountUsd: Double, dayKey: String, ) { if (error is PaymentPendingException) { Logger.info("QuickPay lightning payment pending", context = TAG) pendingPaymentRepo.track(error.paymentHash) + cacheStore.rememberQuickPayReservation( + paymentHash = error.paymentHash, + amountSats = displaySats.toLong(), + dayKey = dayKey, + ) _uiState.update { it.copy( result = QuickPayResult.Pending( @@ -135,7 +138,7 @@ class QuickPayViewModel @Inject constructor( return } Logger.error("QuickPay lightning payment failed", error, context = TAG) - cacheStore.releaseQuickPaySpendUsd(amountUsd, dayKey) + cacheStore.releaseQuickPaySpendSats(displaySats.toLong(), dayKey) setError(error) } @@ -149,12 +152,11 @@ class QuickPayViewModel @Inject constructor( else -> error.message?.takeIf { it.isNotBlank() } ?: context.getString(R.string.common__error_body) } - private suspend fun resolveDailyCapUsd(): Double? { + private suspend fun resolveDailyCapSats(): ULong? { val settings = settingsStore.data.first() val thresholdSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), USD).getOrNull() ?: return null - val dailyCapSats = thresholdSats * settings.quickPayDailyLimitMultiplier.toULong() - return currencyRepo.convertSatsToFiat(dailyCapSats.toLong(), USD).getOrNull()?.value?.toDouble() + return thresholdSats * settings.quickPayDailyLimitMultiplier.toULong() } private suspend fun sendLightning( @@ -194,7 +196,6 @@ private data class PreparedQuickPay( val bolt11: String, val amount: ULong?, val displaySats: ULong, - val amountUsd: Double, ) private data class SettledQuickPayPayment( diff --git a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt index c595d7f47..6083042a3 100644 --- a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt +++ b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt @@ -93,42 +93,72 @@ class CacheStoreTest : BaseUnitTest() { } @Test - fun `quickPaySpentUsdForDay returns spend for matching day key`() = test { - sut.recordQuickPaySpendUsd(amountUsd = 3.5, dayKey = "2026-08-15") + fun `quickPaySpentSatsForDay returns spend for matching day key`() = test { + sut.recordQuickPaySpendSats(amountSats = 3500L, dayKey = "2026-08-15") - assertEquals(3.5, sut.quickPaySpentUsdForDay("2026-08-15")) + assertEquals(3500L, sut.quickPaySpentSatsForDay("2026-08-15")) } @Test - fun `quickPaySpentUsdForDay returns zero for a different day key`() = test { - sut.recordQuickPaySpendUsd(amountUsd = 12.0, dayKey = "2026-08-14") + fun `quickPaySpentSatsForDay returns zero for a later day key`() = test { + sut.recordQuickPaySpendSats(amountSats = 12_000L, dayKey = "2026-08-14") - assertEquals(0.0, sut.quickPaySpentUsdForDay("2026-08-15")) + assertEquals(0L, sut.quickPaySpentSatsForDay("2026-08-15")) } @Test - fun `recordQuickPaySpendUsd accumulates on the same day and resets on a new day`() = test { - sut.recordQuickPaySpendUsd(amountUsd = 2.0, dayKey = "2026-08-15") - sut.recordQuickPaySpendUsd(amountUsd = 1.5, dayKey = "2026-08-15") - assertEquals(3.5, sut.quickPaySpentUsdForDay("2026-08-15")) - - sut.recordQuickPaySpendUsd(amountUsd = 4.0, dayKey = "2026-08-16") - assertEquals(4.0, sut.quickPaySpentUsdForDay("2026-08-16")) - assertEquals(0.0, sut.quickPaySpentUsdForDay("2026-08-15")) + fun `quickPaySpentSatsForDay keeps spend on clock rollback`() = test { + sut.recordQuickPaySpendSats(amountSats = 12_000L, dayKey = "2026-08-15") + + assertEquals(12_000L, sut.quickPaySpentSatsForDay("2026-08-14")) + assertTrue(sut.tryReserveQuickPaySpendSats(amountSats = 1_000L, dayKey = "2026-08-14", dailyCapSats = 20_000L)) + assertEquals(13_000L, sut.quickPaySpentSatsForDay("2026-08-14")) + assertEquals(13_000L, sut.quickPaySpentSatsForDay("2026-08-15")) + } + + @Test + fun `recordQuickPaySpendSats accumulates on the same day and resets on a new day`() = test { + sut.recordQuickPaySpendSats(amountSats = 2_000L, dayKey = "2026-08-15") + sut.recordQuickPaySpendSats(amountSats = 1_500L, dayKey = "2026-08-15") + assertEquals(3_500L, sut.quickPaySpentSatsForDay("2026-08-15")) + + sut.recordQuickPaySpendSats(amountSats = 4_000L, dayKey = "2026-08-16") + assertEquals(4_000L, sut.quickPaySpentSatsForDay("2026-08-16")) + assertEquals(0L, sut.quickPaySpentSatsForDay("2026-08-15")) + } + + @Test + fun `tryReserveQuickPaySpendSats reserves under the cap and rejects over it`() = test { + assertTrue(sut.tryReserveQuickPaySpendSats(amountSats = 10_000L, dayKey = "2026-08-15", dailyCapSats = 25_000L)) + assertTrue(sut.tryReserveQuickPaySpendSats(amountSats = 10_000L, dayKey = "2026-08-15", dailyCapSats = 25_000L)) + assertFalse(sut.tryReserveQuickPaySpendSats(amountSats = 10_000L, dayKey = "2026-08-15", dailyCapSats = 25_000L)) + assertEquals(20_000L, sut.quickPaySpentSatsForDay("2026-08-15")) } @Test - fun `tryReserveQuickPaySpendUsd reserves under the cap and rejects over it`() = test { - assertTrue(sut.tryReserveQuickPaySpendUsd(amountUsd = 10.0, dayKey = "2026-08-15", dailyCapUsd = 25.0)) - assertTrue(sut.tryReserveQuickPaySpendUsd(amountUsd = 10.0, dayKey = "2026-08-15", dailyCapUsd = 25.0)) - assertFalse(sut.tryReserveQuickPaySpendUsd(amountUsd = 10.0, dayKey = "2026-08-15", dailyCapUsd = 25.0)) - assertEquals(20.0, sut.quickPaySpentUsdForDay("2026-08-15")) + fun `releaseQuickPaySpendSats rolls back a reservation`() = test { + assertTrue(sut.tryReserveQuickPaySpendSats(amountSats = 5_000L, dayKey = "2026-08-15", dailyCapSats = 25_000L)) + sut.releaseQuickPaySpendSats(amountSats = 5_000L, dayKey = "2026-08-15") + assertEquals(0L, sut.quickPaySpentSatsForDay("2026-08-15")) } @Test - fun `releaseQuickPaySpendUsd rolls back a reservation`() = test { - assertTrue(sut.tryReserveQuickPaySpendUsd(amountUsd = 5.0, dayKey = "2026-08-15", dailyCapUsd = 25.0)) - sut.releaseQuickPaySpendUsd(amountUsd = 5.0, dayKey = "2026-08-15") - assertEquals(0.0, sut.quickPaySpentUsdForDay("2026-08-15")) + fun `releaseQuickPayReservation frees pending spend by payment hash`() = test { + assertTrue(sut.tryReserveQuickPaySpendSats(amountSats = 5_000L, dayKey = "2026-08-15", dailyCapSats = 25_000L)) + sut.rememberQuickPayReservation(paymentHash = "abc", amountSats = 5_000L, dayKey = "2026-08-15") + + sut.releaseQuickPayReservation("abc") + + assertEquals(0L, sut.quickPaySpentSatsForDay("2026-08-15")) + } + + @Test + fun `clearQuickPayReservation keeps spend after success`() = test { + assertTrue(sut.tryReserveQuickPaySpendSats(amountSats = 5_000L, dayKey = "2026-08-15", dailyCapSats = 25_000L)) + sut.rememberQuickPayReservation(paymentHash = "abc", amountSats = 5_000L, dayKey = "2026-08-15") + + sut.clearQuickPayReservation("abc") + + assertEquals(5_000L, sut.quickPaySpentSatsForDay("2026-08-15")) } } diff --git a/app/src/test/java/to/bitkit/ui/components/StepSliderMappingTest.kt b/app/src/test/java/to/bitkit/ui/components/StepSliderMappingTest.kt new file mode 100644 index 000000000..0e19a67be --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/components/StepSliderMappingTest.kt @@ -0,0 +1,25 @@ +package to.bitkit.ui.components + +import kotlin.test.Test +import kotlin.test.assertEquals + +class StepSliderMappingTest { + + @Test + fun `pointer mapping mirrors physical x in rtl`() { + assertEquals(20f, sliderPointerToLogical(x = 80f, width = 100f, isRtl = true)) + assertEquals(80f, sliderPointerToLogical(x = 80f, width = 100f, isRtl = false)) + } + + @Test + fun `visual mapping mirrors logical x in rtl`() { + assertEquals(20f, sliderLogicalToVisual(x = 80f, width = 100f, isRtl = true)) + assertEquals(80f, sliderLogicalToVisual(x = 80f, width = 100f, isRtl = false)) + } + + @Test + fun `drag delta flips in rtl so thumb follows the finger`() { + assertEquals(-12f, sliderDragDeltaToLogical(deltaX = 12f, isRtl = true)) + assertEquals(12f, sliderDragDeltaToLogical(deltaX = 12f, isRtl = false)) + } +} diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 3fb56fff6..7b7dbc6f2 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -225,7 +225,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(backupRepo.isRestoring).thenReturn(MutableStateFlow(false)) stubSettingsStore() whenever(cacheStore.data).thenReturn(flowOf(AppCacheData())) - whenever { cacheStore.quickPaySpentUsdForDay(any()) }.thenReturn(0.0) + whenever { cacheStore.quickPaySpentSatsForDay(any()) }.thenReturn(0L) + whenever { cacheStore.clearQuickPayReservation(any()) }.thenReturn(Unit) + whenever { cacheStore.releaseQuickPayReservation(any()) }.thenReturn(Unit) + 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)) @@ -1718,6 +1722,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(pendingPaymentRepo).resolve(PendingPaymentResolution.Success(paymentHash)) verify(activityRepo).setContact(contactPublicKey = contactKey, forPaymentId = paymentHash) + verify(cacheStore).clearQuickPayReservation(paymentHash) } @Test @@ -1738,6 +1743,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() verify(pendingPaymentRepo).resolve(PendingPaymentResolution.Failure(paymentHash)) + verify(cacheStore).releaseQuickPayReservation(paymentHash) assertNull(pendingContactPaymentContext(paymentHash)) } @@ -2206,7 +2212,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `lightning scan skips QuickPay when daily spend cap is exceeded`() = test { val bolt11 = "lnbcrt1quickpaycap" - enableQuickPay(thresholdSats = 1000u, spentUsdToday = 24.0) + enableQuickPay(thresholdSats = 1000u, spentSatsToday = 4_600L) settingsData.value = settingsData.value.copy(quickPayDailyLimitMultiplier = 5) stubLightningScan(bolt11 = bolt11, amountSats = 500u) sut.setIsAuthenticated(true) @@ -3227,7 +3233,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private fun enableQuickPay( thresholdSats: ULong, - spentUsdToday: Double = 0.0, + spentSatsToday: Long = 0L, ) { settingsData.value = SettingsData(isQuickPayEnabled = true, quickPayAmount = 5) whenever(currencyRepo.convertFiatToSats(5.0, "USD")).thenReturn(Result.success(thresholdSats)) @@ -3244,7 +3250,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { locale = Locale.US, ) } - whenever { cacheStore.quickPaySpentUsdForDay(any()) }.thenReturn(spentUsdToday) + whenever { cacheStore.quickPaySpentSatsForDay(any()) }.thenReturn(spentSatsToday) } private suspend fun stubLightningScan(bolt11: String, amountSats: ULong) { From a254ba254f6cd88b3862e5724e90966841759e4a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 18 Aug 2026 21:51:03 +0200 Subject: [PATCH 16/71] test: fix quickpay day spend assertion --- app/src/test/java/to/bitkit/data/CacheStoreTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt index 6083042a3..942fba825 100644 --- a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt +++ b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt @@ -124,7 +124,7 @@ class CacheStoreTest : BaseUnitTest() { sut.recordQuickPaySpendSats(amountSats = 4_000L, dayKey = "2026-08-16") assertEquals(4_000L, sut.quickPaySpentSatsForDay("2026-08-16")) - assertEquals(0L, sut.quickPaySpentSatsForDay("2026-08-15")) + assertEquals(4_000L, sut.quickPaySpentSatsForDay("2026-08-15")) } @Test From d98ad1748306d601ef58ac8a819b7d369527d7ef Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 18 Aug 2026 21:56:17 +0200 Subject: [PATCH 17/71] fix: wrap cache store test lines --- .../java/to/bitkit/data/CacheStoreTest.kt | 56 ++++++++++++++++--- 1 file changed, 49 insertions(+), 7 deletions(-) diff --git a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt index 942fba825..b6e122490 100644 --- a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt +++ b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt @@ -111,7 +111,13 @@ class CacheStoreTest : BaseUnitTest() { sut.recordQuickPaySpendSats(amountSats = 12_000L, dayKey = "2026-08-15") assertEquals(12_000L, sut.quickPaySpentSatsForDay("2026-08-14")) - assertTrue(sut.tryReserveQuickPaySpendSats(amountSats = 1_000L, dayKey = "2026-08-14", dailyCapSats = 20_000L)) + assertTrue( + sut.tryReserveQuickPaySpendSats( + amountSats = 1_000L, + dayKey = "2026-08-14", + dailyCapSats = 20_000L, + ), + ) assertEquals(13_000L, sut.quickPaySpentSatsForDay("2026-08-14")) assertEquals(13_000L, sut.quickPaySpentSatsForDay("2026-08-15")) } @@ -129,22 +135,52 @@ class CacheStoreTest : BaseUnitTest() { @Test fun `tryReserveQuickPaySpendSats reserves under the cap and rejects over it`() = test { - assertTrue(sut.tryReserveQuickPaySpendSats(amountSats = 10_000L, dayKey = "2026-08-15", dailyCapSats = 25_000L)) - assertTrue(sut.tryReserveQuickPaySpendSats(amountSats = 10_000L, dayKey = "2026-08-15", dailyCapSats = 25_000L)) - assertFalse(sut.tryReserveQuickPaySpendSats(amountSats = 10_000L, dayKey = "2026-08-15", dailyCapSats = 25_000L)) + assertTrue( + sut.tryReserveQuickPaySpendSats( + amountSats = 10_000L, + dayKey = "2026-08-15", + dailyCapSats = 25_000L, + ), + ) + assertTrue( + sut.tryReserveQuickPaySpendSats( + amountSats = 10_000L, + dayKey = "2026-08-15", + dailyCapSats = 25_000L, + ), + ) + assertFalse( + sut.tryReserveQuickPaySpendSats( + amountSats = 10_000L, + dayKey = "2026-08-15", + dailyCapSats = 25_000L, + ), + ) assertEquals(20_000L, sut.quickPaySpentSatsForDay("2026-08-15")) } @Test fun `releaseQuickPaySpendSats rolls back a reservation`() = test { - assertTrue(sut.tryReserveQuickPaySpendSats(amountSats = 5_000L, dayKey = "2026-08-15", dailyCapSats = 25_000L)) + assertTrue( + sut.tryReserveQuickPaySpendSats( + amountSats = 5_000L, + dayKey = "2026-08-15", + dailyCapSats = 25_000L, + ), + ) sut.releaseQuickPaySpendSats(amountSats = 5_000L, dayKey = "2026-08-15") assertEquals(0L, sut.quickPaySpentSatsForDay("2026-08-15")) } @Test fun `releaseQuickPayReservation frees pending spend by payment hash`() = test { - assertTrue(sut.tryReserveQuickPaySpendSats(amountSats = 5_000L, dayKey = "2026-08-15", dailyCapSats = 25_000L)) + assertTrue( + sut.tryReserveQuickPaySpendSats( + amountSats = 5_000L, + dayKey = "2026-08-15", + dailyCapSats = 25_000L, + ), + ) sut.rememberQuickPayReservation(paymentHash = "abc", amountSats = 5_000L, dayKey = "2026-08-15") sut.releaseQuickPayReservation("abc") @@ -154,7 +190,13 @@ class CacheStoreTest : BaseUnitTest() { @Test fun `clearQuickPayReservation keeps spend after success`() = test { - assertTrue(sut.tryReserveQuickPaySpendSats(amountSats = 5_000L, dayKey = "2026-08-15", dailyCapSats = 25_000L)) + assertTrue( + sut.tryReserveQuickPaySpendSats( + amountSats = 5_000L, + dayKey = "2026-08-15", + dailyCapSats = 25_000L, + ), + ) sut.rememberQuickPayReservation(paymentHash = "abc", amountSats = 5_000L, dayKey = "2026-08-15") sut.clearQuickPayReservation("abc") From c0806a624e748d78537e2727d876ebecc30cd01b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 19 Aug 2026 16:51:37 +0200 Subject: [PATCH 18/71] fix: account quickpay daily spend in usd cents --- .../main/java/to/bitkit/data/CacheStore.kt | 52 +++-- .../main/java/to/bitkit/models/Currency.kt | 2 + .../wallets/send/SendQuickPayScreen.kt | 2 + .../java/to/bitkit/ui/sheets/SendSheet.kt | 5 + .../java/to/bitkit/viewmodels/AppViewModel.kt | 51 +++-- .../to/bitkit/viewmodels/QuickPayViewModel.kt | 96 +++++---- .../java/to/bitkit/data/CacheStoreTest.kt | 134 +++++++----- .../viewmodels/AppViewModelSendFlowTest.kt | 78 ++++++- .../viewmodels/QuickPayViewModelTest.kt | 198 ++++++++++++++++++ 9 files changed, 488 insertions(+), 130 deletions(-) create mode 100644 app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index e97ade4ba..98d9ac24f 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -139,63 +139,71 @@ class CacheStore @Inject constructor( store.updateData { it.copy(backgroundReceive = null) } } - suspend fun quickPaySpentSatsForDay(dayKey: String): Long { + suspend fun quickPaySpentCentsForDay(dayKey: String): Long { val data = store.data.first() - return data.quickPaySpendFor(dayKey).spentSats + return data.quickPaySpendFor(dayKey).spentCents } - suspend fun tryReserveQuickPaySpendSats(amountSats: Long, dayKey: String, dailyCapSats: Long): Boolean { + suspend fun tryReserveQuickPaySpendCents(amountCents: Long, dayKey: String, dailyCapCents: Long): Boolean { var reserved = false store.updateData { val spend = it.quickPaySpendFor(dayKey) - if (spend.spentSats + amountSats > dailyCapSats) return@updateData it + if (spend.spentCents + amountCents > dailyCapCents) return@updateData it reserved = true it.copy( quickPaySpendDayKey = spend.dayKey, - quickPaySpentSatsToday = spend.spentSats + amountSats, + quickPaySpentCentsToday = spend.spentCents + amountCents, ) } return reserved } - suspend fun releaseQuickPaySpendSats(amountSats: Long, dayKey: String) { + suspend fun releaseQuickPaySpendCents(amountCents: Long, dayKey: String) { store.updateData { val spend = it.quickPaySpendFor(dayKey) if (spend.dayKey != it.quickPaySpendDayKey) return@updateData it - it.copy(quickPaySpentSatsToday = (spend.spentSats - amountSats).coerceAtLeast(0L)) + it.copy(quickPaySpentCentsToday = (spend.spentCents - amountCents).coerceAtLeast(0L)) } } - suspend fun recordQuickPaySpendSats(amountSats: Long, dayKey: String) { + suspend fun recordQuickPaySpendCents(amountCents: Long, dayKey: String) { store.updateData { val spend = it.quickPaySpendFor(dayKey) it.copy( quickPaySpendDayKey = spend.dayKey, - quickPaySpentSatsToday = spend.spentSats + amountSats, + quickPaySpentCentsToday = spend.spentCents + amountCents, ) } } - suspend fun rememberQuickPayReservation(paymentHash: String, amountSats: Long, dayKey: String) { + suspend fun rememberQuickPayReservation(paymentHash: String, amountCents: Long, dayKey: String) { if (paymentHash.isBlank()) return store.updateData { it.copy( quickPayReservations = it.quickPayReservations + ( - paymentHash to QuickPaySpendReservation(amountSats = amountSats, dayKey = dayKey) + paymentHash to QuickPaySpendReservation(amountCents = amountCents, dayKey = dayKey) ), ) } } + suspend fun quickPayReservation(paymentHash: String): QuickPaySpendReservation? { + if (paymentHash.isBlank()) return null + return store.data.first().quickPayReservations[paymentHash] + } + suspend fun releaseQuickPayReservation(paymentHash: String) { if (paymentHash.isBlank()) return store.updateData { data -> val reservation = data.quickPayReservations[paymentHash] ?: return@updateData data + val remaining = data.quickPayReservations - paymentHash val spend = data.quickPaySpendFor(reservation.dayKey) + if (reservation.dayKey != spend.dayKey) { + return@updateData data.copy(quickPayReservations = remaining) + } data.copy( - quickPaySpendDayKey = spend.dayKey, - quickPaySpentSatsToday = (spend.spentSats - reservation.amountSats).coerceAtLeast(0L), - quickPayReservations = data.quickPayReservations - paymentHash, + quickPaySpentCentsToday = (spend.spentCents - reservation.amountCents).coerceAtLeast(0L), + quickPayReservations = remaining, ) } } @@ -234,7 +242,7 @@ data class AppCacheData( val addressSearchLastUsedReceiveIndexes: Map = mapOf(), val addressSearchLastUsedChangeIndexes: Map = mapOf(), val quickPaySpendDayKey: String = "", - val quickPaySpentSatsToday: Long = 0L, + val quickPaySpentCentsToday: Long = 0L, val quickPayReservations: Map = emptyMap(), ) { fun isActivityDeleted(activityId: String, walletId: String): Boolean = @@ -249,18 +257,24 @@ data class AppCacheData( fun quickPaySpendFor(dayKey: String): QuickPayDaySpend = when { quickPaySpendDayKey.isEmpty() || dayKey > quickPaySpendDayKey -> QuickPayDaySpend(dayKey, 0L) - dayKey == quickPaySpendDayKey -> QuickPayDaySpend(dayKey, quickPaySpentSatsToday) - else -> QuickPayDaySpend(quickPaySpendDayKey, quickPaySpentSatsToday) + dayKey == quickPaySpendDayKey -> QuickPayDaySpend(dayKey, quickPaySpentCentsToday) + else -> QuickPayDaySpend(quickPaySpendDayKey, quickPaySpentCentsToday) } } @Serializable data class QuickPaySpendReservation( - val amountSats: Long, + val amountCents: Long, val dayKey: String, ) data class QuickPayDaySpend( val dayKey: String, - val spentSats: Long, + val spentCents: Long, ) + +fun quickPayCapCents(thresholdUsd: Int, multiplier: Int): Long = + thresholdUsd.toLong() * 100L * multiplier.toLong() + +fun quickPayReserveCents(convertedCents: Long, thresholdUsd: Int): Long = + minOf(convertedCents, thresholdUsd.toLong() * 100L) diff --git a/app/src/main/java/to/bitkit/models/Currency.kt b/app/src/main/java/to/bitkit/models/Currency.kt index 7925a03ed..c74930db7 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/ui/screens/wallets/send/SendQuickPayScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendQuickPayScreen.kt index 5f03bbe4c..75ae2ea49 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 @@ -39,6 +39,7 @@ fun SendQuickPayScreen( quickPayData: QuickPayData, onPaymentComplete: (String, Long) -> Unit, onPaymentPending: (String, Long, String) -> Unit, + onFallBackToConfirm: () -> Unit, onShowError: (SendFailureDetails) -> Unit, viewModel: QuickPayViewModel = hiltViewModel(), ) { @@ -59,6 +60,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/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index 814aeb999..af475af15 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,11 @@ fun SendSheet( popUpTo(startDestination) { inclusive = true } } }, + onFallBackToConfirm = { + navController.navigateTo(SendRoute.Confirm) { + popUpTo { inclusive = true } + } + }, onShowError = { failure -> appViewModel.clearActiveContactPaymentContext() navController.navigateTo( diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 01519589c..e366e1ab8 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -77,6 +77,8 @@ import to.bitkit.data.CacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain +import to.bitkit.data.quickPayCapCents +import to.bitkit.data.quickPayReserveCents import to.bitkit.data.resetPin import to.bitkit.di.BgDispatcher import to.bitkit.domain.commands.NotifyChannelReady @@ -1240,13 +1242,18 @@ class AppViewModel @Inject constructor( activityRepo.handlePaymentEvent(paymentHash) if (pendingPaymentRepo.isPending(paymentHash)) { syncContactForActivity(paymentHash) + val isQuickPay = cacheStore.quickPayReservation(paymentHash) != null cacheStore.clearQuickPayReservation(paymentHash) - val amountWithFeeSats = activityRepo.findActivityByPaymentId( - paymentHashOrTxId = paymentHash, - type = ActivityFilter.LIGHTNING, - txType = PaymentType.SENT, - retry = true, - ).getOrNull()?.totalValue()?.toLong() + val amountWithFeeSats = if (isQuickPay) { + activityRepo.findActivityByPaymentId( + paymentHashOrTxId = paymentHash, + type = ActivityFilter.LIGHTNING, + txType = PaymentType.SENT, + retry = true, + ).getOrNull()?.totalValue()?.toLong() + } else { + null + } pendingPaymentRepo.resolve( PendingPaymentResolution.Success( paymentHash = paymentHash, @@ -2680,6 +2687,26 @@ class AppViewModel @Inject constructor( _quickPayData.update { quickPayData } + 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, + ) + } + } + Logger.debug("QuickPayData: $quickPayData", context = TAG) navigateToSendRoute(fromMainScanner, SendRoute.QuickPay, SendEffect.NavigateToQuickPay) @@ -2702,14 +2729,14 @@ class AppViewModel @Inject constructor( } private suspend fun isWithinQuickPayDailyCap(amountSats: ULong, settings: SettingsData): Boolean { - val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), USD).getOrNull() - ?: return false - val dailyCapSats = quickPayAmountSats * settings.quickPayDailyLimitMultiplier.toULong() - val spentSatsToday = cacheStore.quickPaySpentSatsForDay(quickPaySpendDayKey()).toULong() - if (spentSatsToday + amountSats <= dailyCapSats) return true + val converted = currencyRepo.convertSatsToFiat(amountSats.toLong(), USD).getOrNull() ?: return false + val reserveCents = quickPayReserveCents(converted.toUsdCents(), settings.quickPayAmount) + val capCents = quickPayCapCents(settings.quickPayAmount, settings.quickPayDailyLimitMultiplier) + val spentCentsToday = cacheStore.quickPaySpentCentsForDay(quickPaySpendDayKey()) + if (spentCentsToday + reserveCents <= capCents) return true Logger.info( - "Skipping QuickPay: daily spend '$spentSatsToday' + '$amountSats' exceeds cap '$dailyCapSats'", + "Skipping QuickPay: daily spend '$spentCentsToday' + '$reserveCents' exceeds cap '$capCents'", context = TAG, ) return false diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index de888d104..f99c029ab 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -16,6 +16,8 @@ import org.lightningdevkit.ldknode.PaymentId import to.bitkit.R import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore +import to.bitkit.data.quickPayCapCents +import to.bitkit.data.quickPayReserveCents import to.bitkit.ext.WatchResult import to.bitkit.ext.callbackAmountMsats import to.bitkit.ext.quickPaySpendDayKey @@ -55,37 +57,43 @@ class QuickPayViewModel @Inject constructor( val lightningState = lightningRepo.lightningState fun pay(data: QuickPayData) { - viewModelScope.launch { - val invoice = resolveQuickPayInvoice(data) ?: return@launch - val dayKey = quickPaySpendDayKey() - if (!reserveSpend(invoice.displaySats, dayKey)) return@launch + viewModelScope.launch { payNow(data) } + } - sendLightning(invoice.bolt11, invoice.amount) - .onSuccess { onPaymentSuccess(it.paymentHash, invoice.displaySats, it.feePaidSats) } - .onFailure { onPaymentFailure(it, invoice, dayKey) } - } + internal suspend fun payNow(data: QuickPayData) { + val invoice = resolveQuickPayInvoice(data) ?: return + val dayKey = quickPaySpendDayKey() + val reservedCents = reserveSpend(invoice.displaySats, dayKey) ?: return + + sendLightning(invoice, reservedCents, dayKey) + .onSuccess { onPaymentSuccess(it.paymentHash, invoice.displaySats, it.feePaidSats) } + .onFailure { onPaymentFailure(it, invoice, reservedCents, dayKey) } } - private suspend fun reserveSpend(amountSats: ULong, dayKey: String): Boolean { - val dailyCapSats = resolveDailyCapSats() - if (dailyCapSats == null) { + private suspend fun reserveSpend(amountSats: ULong, dayKey: String): Long? { + val settings = settingsStore.data.first() + val converted = currencyRepo.convertSatsToFiat(amountSats.toLong(), USD).getOrNull() + if (converted == null) { setError(QuickPayCurrencyConversionError()) - return false + return null } - val reserved = cacheStore.tryReserveQuickPaySpendSats( - amountSats = amountSats.toLong(), + val reserveCents = quickPayReserveCents(converted.toUsdCents(), settings.quickPayAmount) + val reserved = cacheStore.tryReserveQuickPaySpendCents( + amountCents = reserveCents, dayKey = dayKey, - dailyCapSats = dailyCapSats.toLong(), + dailyCapCents = quickPayCapCents(settings.quickPayAmount, settings.quickPayDailyLimitMultiplier), ) if (!reserved) { Logger.info("Skipping QuickPay pay: daily spend reserve failed for '$amountSats'", context = TAG) - setError(QuickPayDailyLimitReachedError()) + _uiState.update { it.copy(result = QuickPayResult.FallBackToConfirm) } + return null } - return reserved + return reserveCents } - private fun onPaymentSuccess(paymentHash: String, displaySats: ULong, feePaidSats: ULong) { + private suspend fun onPaymentSuccess(paymentHash: String, displaySats: ULong, feePaidSats: ULong) { Logger.info("QuickPay lightning payment successful") + cacheStore.clearQuickPayReservation(paymentHash) _uiState.update { it.copy( result = QuickPayResult.Success( @@ -99,16 +107,11 @@ class QuickPayViewModel @Inject constructor( private suspend fun onPaymentFailure( error: Throwable, invoice: QuickPayInvoice, + reservedCents: Long, dayKey: String, ) { if (error is PaymentPendingException) { Logger.info("QuickPay lightning payment pending", context = TAG) - pendingPaymentRepo.track(error.paymentHash) - cacheStore.rememberQuickPayReservation( - paymentHash = error.paymentHash, - amountSats = invoice.displaySats.toLong(), - dayKey = dayKey, - ) _uiState.update { it.copy( result = QuickPayResult.Pending( @@ -121,7 +124,11 @@ class QuickPayViewModel @Inject constructor( return } Logger.error("QuickPay lightning payment failed", error, context = TAG) - cacheStore.releaseQuickPaySpendSats(invoice.displaySats.toLong(), dayKey) + if (error is QuickPayPaymentFailedError) { + cacheStore.releaseQuickPayReservation(error.paymentHash) + } else { + cacheStore.releaseQuickPaySpendCents(reservedCents, dayKey) + } handleQuickPayFailure(error, invoice) } @@ -130,9 +137,6 @@ class QuickPayViewModel @Inject constructor( is QuickPayCurrencyConversionError -> { context.getString(R.string.wallet__send_quickpay__currency_conversion) } - is QuickPayDailyLimitReachedError -> { - context.getString(R.string.wallet__send_quickpay__daily_limit) - } else -> null } val failure = if (localizedMessage != null) { @@ -148,13 +152,6 @@ class QuickPayViewModel @Inject constructor( _uiState.update { it.copy(result = QuickPayResult.Error(failure)) } } - private suspend fun resolveDailyCapSats(): ULong? { - val settings = settingsStore.data.first() - val thresholdSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), USD).getOrNull() - ?: return null - return thresholdSats * settings.quickPayDailyLimitMultiplier.toULong() - } - private suspend fun resolveQuickPayInvoice(data: QuickPayData): QuickPayInvoice? { return when (data) { is QuickPayData.Bolt11 -> { @@ -195,15 +192,22 @@ class QuickPayViewModel @Inject constructor( } private suspend fun sendLightning( - bolt11: String, - amount: ULong? = null, + invoice: QuickPayInvoice, + reservedCents: Long, + dayKey: String, ): Result { - val hash = lightningRepo.payInvoice(bolt11 = bolt11, sats = amount) + val hash = lightningRepo.payInvoice(bolt11 = invoice.bolt11, sats = invoice.amount) .onFailure { exception -> return Result.failure(exception) } .getOrDefault("") + cacheStore.rememberQuickPayReservation( + paymentHash = hash, + amountCents = reservedCents, + dayKey = dayKey, + ) + // Wait until matching payment event is received (with timeout for hold invoices) val result = lightningRepo.nodeEvents.watchUntil(LightningRepo.SEND_LN_TIMEOUT) { when (it) { @@ -218,14 +222,21 @@ class QuickPayViewModel @Inject constructor( is Event.PaymentFailed if it.paymentHash == hash -> WatchResult.Complete( Result.failure( - QuickPayPaymentFailedError(reason = it.reason, paymentRequest = bolt11) + QuickPayPaymentFailedError( + paymentHash = hash, + reason = it.reason, + paymentRequest = invoice.bolt11, + ) ) ) else -> WatchResult.Continue() } } - return result ?: Result.failure(PaymentPendingException(hash)) + if (result != null) return result + + pendingPaymentRepo.track(hash) + return Result.failure(PaymentPendingException(hash)) } } @@ -236,8 +247,6 @@ private data class SettledQuickPayPayment( private class QuickPayCurrencyConversionError : AppError("Currency conversion failed") -private class QuickPayDailyLimitReachedError : AppError("Daily QuickPay limit reached") - sealed class QuickPayResult { data class Success( val paymentHash: String, @@ -250,6 +259,8 @@ sealed class QuickPayResult { val paymentRequest: String, ) : QuickPayResult() + data object FallBackToConfirm : QuickPayResult() + data class Error(val failure: SendFailureDetails) : QuickPayResult() } @@ -267,6 +278,7 @@ private data class QuickPayInvoice( } private class QuickPayPaymentFailedError( + val paymentHash: String, val reason: PaymentFailureReason?, val paymentRequest: String?, ) : AppError(reason?.name) diff --git a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt index b6e122490..4cc1ad77d 100644 --- a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt +++ b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt @@ -11,10 +11,12 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import to.bitkit.di.json import to.bitkit.ext.scopedActivityId import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue @Config(application = Application::class, sdk = [34]) @@ -93,114 +95,148 @@ class CacheStoreTest : BaseUnitTest() { } @Test - fun `quickPaySpentSatsForDay returns spend for matching day key`() = test { - sut.recordQuickPaySpendSats(amountSats = 3500L, dayKey = "2026-08-15") + fun `quickPaySpentCentsForDay returns spend for matching day key`() = test { + sut.recordQuickPaySpendCents(amountCents = 3500L, dayKey = "2026-08-15") - assertEquals(3500L, sut.quickPaySpentSatsForDay("2026-08-15")) + assertEquals(3500L, sut.quickPaySpentCentsForDay("2026-08-15")) } @Test - fun `quickPaySpentSatsForDay returns zero for a later day key`() = test { - sut.recordQuickPaySpendSats(amountSats = 12_000L, dayKey = "2026-08-14") + fun `quickPaySpentCentsForDay returns zero for a later day key`() = test { + sut.recordQuickPaySpendCents(amountCents = 12_000L, dayKey = "2026-08-14") - assertEquals(0L, sut.quickPaySpentSatsForDay("2026-08-15")) + assertEquals(0L, sut.quickPaySpentCentsForDay("2026-08-15")) } @Test - fun `quickPaySpentSatsForDay keeps spend on clock rollback`() = test { - sut.recordQuickPaySpendSats(amountSats = 12_000L, dayKey = "2026-08-15") + fun `quickPaySpentCentsForDay keeps spend on clock rollback`() = test { + sut.recordQuickPaySpendCents(amountCents = 12_000L, dayKey = "2026-08-15") - assertEquals(12_000L, sut.quickPaySpentSatsForDay("2026-08-14")) + assertEquals(12_000L, sut.quickPaySpentCentsForDay("2026-08-14")) assertTrue( - sut.tryReserveQuickPaySpendSats( - amountSats = 1_000L, + sut.tryReserveQuickPaySpendCents( + amountCents = 1_000L, dayKey = "2026-08-14", - dailyCapSats = 20_000L, + dailyCapCents = 20_000L, ), ) - assertEquals(13_000L, sut.quickPaySpentSatsForDay("2026-08-14")) - assertEquals(13_000L, sut.quickPaySpentSatsForDay("2026-08-15")) + assertEquals(13_000L, sut.quickPaySpentCentsForDay("2026-08-14")) + assertEquals(13_000L, sut.quickPaySpentCentsForDay("2026-08-15")) } @Test - fun `recordQuickPaySpendSats accumulates on the same day and resets on a new day`() = test { - sut.recordQuickPaySpendSats(amountSats = 2_000L, dayKey = "2026-08-15") - sut.recordQuickPaySpendSats(amountSats = 1_500L, dayKey = "2026-08-15") - assertEquals(3_500L, sut.quickPaySpentSatsForDay("2026-08-15")) - - sut.recordQuickPaySpendSats(amountSats = 4_000L, dayKey = "2026-08-16") - assertEquals(4_000L, sut.quickPaySpentSatsForDay("2026-08-16")) - assertEquals(4_000L, sut.quickPaySpentSatsForDay("2026-08-15")) + fun `recordQuickPaySpendCents accumulates on the same day and resets on a new day`() = test { + sut.recordQuickPaySpendCents(amountCents = 2_000L, dayKey = "2026-08-15") + sut.recordQuickPaySpendCents(amountCents = 1_500L, dayKey = "2026-08-15") + assertEquals(3_500L, sut.quickPaySpentCentsForDay("2026-08-15")) + + sut.recordQuickPaySpendCents(amountCents = 4_000L, dayKey = "2026-08-16") + assertEquals(4_000L, sut.quickPaySpentCentsForDay("2026-08-16")) + assertEquals(4_000L, sut.quickPaySpentCentsForDay("2026-08-15")) } @Test - fun `tryReserveQuickPaySpendSats reserves under the cap and rejects over it`() = test { + fun `tryReserveQuickPaySpendCents reserves under the cap and rejects over it`() = test { assertTrue( - sut.tryReserveQuickPaySpendSats( - amountSats = 10_000L, + sut.tryReserveQuickPaySpendCents( + amountCents = 10_000L, dayKey = "2026-08-15", - dailyCapSats = 25_000L, + dailyCapCents = 25_000L, ), ) assertTrue( - sut.tryReserveQuickPaySpendSats( - amountSats = 10_000L, + sut.tryReserveQuickPaySpendCents( + amountCents = 10_000L, dayKey = "2026-08-15", - dailyCapSats = 25_000L, + dailyCapCents = 25_000L, ), ) assertFalse( - sut.tryReserveQuickPaySpendSats( - amountSats = 10_000L, + sut.tryReserveQuickPaySpendCents( + amountCents = 10_000L, dayKey = "2026-08-15", - dailyCapSats = 25_000L, + dailyCapCents = 25_000L, ), ) - assertEquals(20_000L, sut.quickPaySpentSatsForDay("2026-08-15")) + assertEquals(20_000L, sut.quickPaySpentCentsForDay("2026-08-15")) } @Test - fun `releaseQuickPaySpendSats rolls back a reservation`() = test { + fun `releaseQuickPaySpendCents rolls back a reservation`() = test { assertTrue( - sut.tryReserveQuickPaySpendSats( - amountSats = 5_000L, + sut.tryReserveQuickPaySpendCents( + amountCents = 5_000L, dayKey = "2026-08-15", - dailyCapSats = 25_000L, + dailyCapCents = 25_000L, ), ) - sut.releaseQuickPaySpendSats(amountSats = 5_000L, dayKey = "2026-08-15") - assertEquals(0L, sut.quickPaySpentSatsForDay("2026-08-15")) + sut.releaseQuickPaySpendCents(amountCents = 5_000L, dayKey = "2026-08-15") + assertEquals(0L, sut.quickPaySpentCentsForDay("2026-08-15")) } @Test fun `releaseQuickPayReservation frees pending spend by payment hash`() = test { assertTrue( - sut.tryReserveQuickPaySpendSats( - amountSats = 5_000L, + sut.tryReserveQuickPaySpendCents( + amountCents = 5_000L, dayKey = "2026-08-15", - dailyCapSats = 25_000L, + dailyCapCents = 25_000L, ), ) - sut.rememberQuickPayReservation(paymentHash = "abc", amountSats = 5_000L, dayKey = "2026-08-15") + sut.rememberQuickPayReservation(paymentHash = "abc", amountCents = 5_000L, dayKey = "2026-08-15") sut.releaseQuickPayReservation("abc") - assertEquals(0L, sut.quickPaySpentSatsForDay("2026-08-15")) + assertEquals(0L, sut.quickPaySpentCentsForDay("2026-08-15")) } @Test fun `clearQuickPayReservation keeps spend after success`() = test { assertTrue( - sut.tryReserveQuickPaySpendSats( - amountSats = 5_000L, + sut.tryReserveQuickPaySpendCents( + amountCents = 5_000L, dayKey = "2026-08-15", - dailyCapSats = 25_000L, + dailyCapCents = 25_000L, ), ) - sut.rememberQuickPayReservation(paymentHash = "abc", amountSats = 5_000L, dayKey = "2026-08-15") + sut.rememberQuickPayReservation(paymentHash = "abc", amountCents = 5_000L, dayKey = "2026-08-15") sut.clearQuickPayReservation("abc") - assertEquals(5_000L, sut.quickPaySpentSatsForDay("2026-08-15")) + assertEquals(5_000L, sut.quickPaySpentCentsForDay("2026-08-15")) + } + + @Test + fun `releaseQuickPayReservation on a prior day does not decrement the new day`() = test { + assertTrue( + sut.tryReserveQuickPaySpendCents( + amountCents = 5_000L, + dayKey = "2026-08-15", + dailyCapCents = 25_000L, + ), + ) + sut.rememberQuickPayReservation(paymentHash = "old", amountCents = 5_000L, dayKey = "2026-08-15") + assertTrue( + sut.tryReserveQuickPaySpendCents( + amountCents = 4_000L, + dayKey = "2026-08-16", + dailyCapCents = 25_000L, + ), + ) + + sut.releaseQuickPayReservation("old") + + assertEquals(4_000L, sut.quickPaySpentCentsForDay("2026-08-16")) + assertNull(sut.quickPayReservation("old")) + } + + @Test + fun `old sat spend field is not read as cents`() { + val data = json.decodeFromString( + """{"quickPaySpendDayKey":"2026-08-15","quickPaySpentSatsToday":20000}""", + ) + + assertEquals(0L, data.quickPaySpentCentsToday) + assertEquals("2026-08-15", data.quickPaySpendDayKey) } } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index eebe9b6af..2f607aa5f 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 @@ -55,6 +57,7 @@ import to.bitkit.CurrentActivity import to.bitkit.R import to.bitkit.data.AppCacheData import to.bitkit.data.CacheStore +import to.bitkit.data.QuickPaySpendReservation import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain @@ -227,7 +230,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(backupRepo.isRestoring).thenReturn(MutableStateFlow(false)) stubSettingsStore() whenever(cacheStore.data).thenReturn(flowOf(AppCacheData())) - whenever { cacheStore.quickPaySpentSatsForDay(any()) }.thenReturn(0L) + whenever { cacheStore.quickPaySpentCentsForDay(any()) }.thenReturn(0L) + whenever { cacheStore.quickPayReservation(any()) }.thenReturn(null) whenever { cacheStore.clearQuickPayReservation(any()) }.thenReturn(Unit) whenever { cacheStore.releaseQuickPayReservation(any()) }.thenReturn(Unit) whenever { activityRepo.findActivityByPaymentId(any(), any(), any(), any()) } @@ -1754,6 +1758,64 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertNull(pendingContactPaymentContext(paymentHash)) } + @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 { cacheStore.quickPayReservation(paymentHash) }.thenReturn(null) + 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 10u + } + val activity = mock { on { v1 } doReturn activityV1 } + whenever(pendingPaymentRepo.isPending(paymentHash)).thenReturn(true) + whenever(pendingPaymentRepo.isActive(paymentHash)).thenReturn(false) + whenever { cacheStore.quickPayReservation(paymentHash) }.thenReturn( + QuickPaySpendReservation(amountCents = 250L, dayKey = "2026-08-15"), + ) + whenever { activityRepo.findActivityByPaymentId(any(), any(), any(), any()) } + .thenReturn(Result.success(activity)) + advanceUntilIdle() + + emitNodeEvent( + Event.PaymentSuccessful( + paymentId = "payment_id", + paymentHash = paymentHash, + paymentPreimage = "preimage", + feePaidMsat = 10uL, + ), + ) + advanceUntilIdle() + + verify(pendingPaymentRepo).resolve( + PendingPaymentResolution.Success( + paymentHash = paymentHash, + amountWithFeeSats = 510L, + ), + ) + verify(cacheStore).clearQuickPayReservation(paymentHash) + } + @Test fun `active lightning send failure navigates to failure screen`() = test { val bolt11 = "lnbcrt1activefailure" @@ -2181,8 +2243,8 @@ 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) } @@ -2196,8 +2258,8 @@ 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) } @@ -2237,7 +2299,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `lightning scan skips QuickPay when daily spend cap is exceeded`() = test { val bolt11 = "lnbcrt1quickpaycap" - enableQuickPay(thresholdSats = 1000u, spentSatsToday = 4_600L) + enableQuickPay(thresholdSats = 1000u, spentCentsToday = 2_300L) settingsData.value = settingsData.value.copy(quickPayDailyLimitMultiplier = 5) stubLightningScan(bolt11 = bolt11, amountSats = 500u) sut.setIsAuthenticated(true) @@ -3258,7 +3320,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private fun enableQuickPay( thresholdSats: ULong, - spentSatsToday: Long = 0L, + spentCentsToday: Long = 0L, ) { settingsData.value = SettingsData(isQuickPayEnabled = true, quickPayAmount = 5) whenever(currencyRepo.convertFiatToSats(5.0, "USD")).thenReturn(Result.success(thresholdSats)) @@ -3275,7 +3337,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { locale = Locale.US, ) } - whenever { cacheStore.quickPaySpentSatsForDay(any()) }.thenReturn(spentSatsToday) + whenever { cacheStore.quickPaySpentCentsForDay(any()) }.thenReturn(spentCentsToday) } 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 000000000..fc82948b8 --- /dev/null +++ b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt @@ -0,0 +1,198 @@ +package to.bitkit.viewmodels + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.setMain +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.lightningdevkit.ldknode.Event +import org.lightningdevkit.ldknode.PaymentFailureReason +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +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.data.CacheStore +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.ext.quickPaySpendDayKey +import to.bitkit.models.ConvertedAmount +import to.bitkit.models.NodeLifecycleState +import to.bitkit.repositories.CurrencyRepo +import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.LightningState +import to.bitkit.repositories.PendingPaymentRepo +import to.bitkit.test.BaseUnitTest +import java.math.BigDecimal +import java.util.Locale +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class QuickPayViewModelTest : BaseUnitTest() { + private val context: Context = mock() + private val lightningRepo: LightningRepo = mock() + private val pendingPaymentRepo: PendingPaymentRepo = mock() + private val currencyRepo: CurrencyRepo = mock() + private val cacheStore: CacheStore = mock() + private val settingsStore: SettingsStore = mock() + + private lateinit var nodeEvents: MutableSharedFlow + private val settingsData = MutableStateFlow( + SettingsData(isQuickPayEnabled = true, quickPayAmount = 5, quickPayDailyLimitMultiplier = 5), + ) + + private lateinit var sut: QuickPayViewModel + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + nodeEvents = MutableSharedFlow(replay = 1, extraBufferCapacity = 8) + 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(lightningRepo.nodeEvents).thenReturn(nodeEvents) + whenever(settingsStore.data).thenReturn(settingsData) + 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, + ) + } + whenever { cacheStore.tryReserveQuickPaySpendCents(any(), any(), any()) }.thenReturn(true) + whenever { cacheStore.rememberQuickPayReservation(any(), any(), any()) }.thenReturn(Unit) + whenever { cacheStore.clearQuickPayReservation(any()) }.thenReturn(Unit) + whenever { cacheStore.releaseQuickPayReservation(any()) }.thenReturn(Unit) + whenever { cacheStore.releaseQuickPaySpendCents(any(), any()) }.thenReturn(Unit) + sut = QuickPayViewModel( + context = context, + lightningRepo = lightningRepo, + pendingPaymentRepo = pendingPaymentRepo, + currencyRepo = currencyRepo, + cacheStore = cacheStore, + settingsStore = settingsStore, + ) + } + + @Test + fun `happy path reserves before payInvoice and clears reservation on success`() = test { + whenever { lightningRepo.payInvoice(any(), anyOrNull()) }.thenReturn(Result.success("hash1")) + nodeEvents.emit( + Event.PaymentSuccessful( + paymentId = "pid", + paymentHash = "hash1", + paymentPreimage = "preimage", + feePaidMsat = 1_000uL, + ), + ) + + sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) + advanceUntilIdle() + + val order = inOrder(cacheStore, lightningRepo) + order.verify(cacheStore).tryReserveQuickPaySpendCents( + amountCents = 250L, + dayKey = quickPaySpendDayKey(), + dailyCapCents = 2_500L, + ) + order.verify(lightningRepo).payInvoice(bolt11 = "lnbcrt1test", sats = null) + order.verify(cacheStore).rememberQuickPayReservation( + paymentHash = "hash1", + amountCents = 250L, + dayKey = quickPaySpendDayKey(), + ) + order.verify(cacheStore).clearQuickPayReservation("hash1") + verify(pendingPaymentRepo, never()).track(any()) + val success = assertIs(sut.uiState.value.result) + assertEquals("hash1", success.paymentHash) + assertEquals(501L, success.amountWithFee) + } + + @Test + fun `timeout remembers reservation before tracking pending`() = test { + whenever { lightningRepo.payInvoice(any(), anyOrNull()) }.thenReturn(Result.success("hash1")) + + sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) + advanceUntilIdle() + advanceTimeBy(LightningRepo.SEND_LN_TIMEOUT.inWholeMilliseconds + 1) + advanceUntilIdle() + + val order = inOrder(cacheStore, pendingPaymentRepo) + order.verify(cacheStore).rememberQuickPayReservation( + paymentHash = "hash1", + amountCents = 250L, + dayKey = quickPaySpendDayKey(), + ) + order.verify(pendingPaymentRepo).track("hash1") + val pending = assertIs(sut.uiState.value.result) + assertEquals("hash1", pending.paymentHash) + } + + @Test + fun `immediate payInvoice failure releases spend without remembering`() = test { + whenever { lightningRepo.payInvoice(any(), anyOrNull()) } + .thenReturn(Result.failure(IllegalStateException("send failed"))) + + sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) + advanceUntilIdle() + + verify(cacheStore).releaseQuickPaySpendCents(250L, quickPaySpendDayKey()) + verify(cacheStore, never()).rememberQuickPayReservation(any(), any(), any()) + verify(pendingPaymentRepo, never()).track(any()) + assertIs(sut.uiState.value.result) + } + + @Test + fun `payment failed after submit releases hash keyed reservation`() = test { + whenever { lightningRepo.payInvoice(any(), anyOrNull()) }.thenReturn(Result.success("hash1")) + nodeEvents.emit( + Event.PaymentFailed( + paymentId = "pid", + paymentHash = "hash1", + reason = PaymentFailureReason.ROUTE_NOT_FOUND, + ), + ) + + sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) + advanceUntilIdle() + + verify(cacheStore).rememberQuickPayReservation("hash1", 250L, quickPaySpendDayKey()) + verify(cacheStore).releaseQuickPayReservation("hash1") + assertIs(sut.uiState.value.result) + } + + @Test + fun `reserve failure emits FallBackToConfirm`() = test { + whenever { cacheStore.tryReserveQuickPaySpendCents(any(), any(), any()) }.thenReturn(false) + + sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) + advanceUntilIdle() + + assertEquals(QuickPayResult.FallBackToConfirm, sut.uiState.value.result) + verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) + assertNull(sut.uiState.value.result.takeIf { it is QuickPayResult.Error }) + } +} From ddf9b71374cafb3e22de35908333177088ac91b0 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 19 Aug 2026 17:47:53 +0200 Subject: [PATCH 19/71] test: fix quickpay viewmodel mock setup --- .../test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt index fc82948b8..d8726fd2d 100644 --- a/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt @@ -83,10 +83,6 @@ class QuickPayViewModelTest : BaseUnitTest() { ) } whenever { cacheStore.tryReserveQuickPaySpendCents(any(), any(), any()) }.thenReturn(true) - whenever { cacheStore.rememberQuickPayReservation(any(), any(), any()) }.thenReturn(Unit) - whenever { cacheStore.clearQuickPayReservation(any()) }.thenReturn(Unit) - whenever { cacheStore.releaseQuickPayReservation(any()) }.thenReturn(Unit) - whenever { cacheStore.releaseQuickPaySpendCents(any(), any()) }.thenReturn(Unit) sut = QuickPayViewModel( context = context, lightningRepo = lightningRepo, From 6d7bf1852e9003bcaa84bed63750197396a2ea74 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 19 Aug 2026 22:39:45 +0200 Subject: [PATCH 20/71] refactor: move quickpay spend ledger to repo --- .../main/java/to/bitkit/data/CacheStore.kt | 89 -------- .../to/bitkit/repositories/QuickPayRepo.kt | 162 ++++++++++++++ .../java/to/bitkit/viewmodels/AppViewModel.kt | 38 +--- .../to/bitkit/viewmodels/QuickPayViewModel.kt | 55 ++--- .../java/to/bitkit/data/CacheStoreTest.kt | 137 ------------ .../bitkit/repositories/QuickPayRepoTest.kt | 206 ++++++++++++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 62 ++---- .../viewmodels/QuickPayViewModelTest.kt | 76 ++----- 8 files changed, 433 insertions(+), 392 deletions(-) create mode 100644 app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt create mode 100644 app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index 98d9ac24f..6b1b348de 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -139,83 +139,6 @@ class CacheStore @Inject constructor( store.updateData { it.copy(backgroundReceive = null) } } - suspend fun quickPaySpentCentsForDay(dayKey: String): Long { - val data = store.data.first() - return data.quickPaySpendFor(dayKey).spentCents - } - - suspend fun tryReserveQuickPaySpendCents(amountCents: Long, dayKey: String, dailyCapCents: Long): Boolean { - var reserved = false - store.updateData { - val spend = it.quickPaySpendFor(dayKey) - if (spend.spentCents + amountCents > dailyCapCents) return@updateData it - reserved = true - it.copy( - quickPaySpendDayKey = spend.dayKey, - quickPaySpentCentsToday = spend.spentCents + amountCents, - ) - } - return reserved - } - - suspend fun releaseQuickPaySpendCents(amountCents: Long, dayKey: String) { - store.updateData { - val spend = it.quickPaySpendFor(dayKey) - if (spend.dayKey != it.quickPaySpendDayKey) return@updateData it - it.copy(quickPaySpentCentsToday = (spend.spentCents - amountCents).coerceAtLeast(0L)) - } - } - - suspend fun recordQuickPaySpendCents(amountCents: Long, dayKey: String) { - store.updateData { - val spend = it.quickPaySpendFor(dayKey) - it.copy( - quickPaySpendDayKey = spend.dayKey, - quickPaySpentCentsToday = spend.spentCents + amountCents, - ) - } - } - - suspend fun rememberQuickPayReservation(paymentHash: String, amountCents: Long, dayKey: String) { - if (paymentHash.isBlank()) return - store.updateData { - it.copy( - quickPayReservations = it.quickPayReservations + ( - paymentHash to QuickPaySpendReservation(amountCents = amountCents, dayKey = dayKey) - ), - ) - } - } - - suspend fun quickPayReservation(paymentHash: String): QuickPaySpendReservation? { - if (paymentHash.isBlank()) return null - return store.data.first().quickPayReservations[paymentHash] - } - - suspend fun releaseQuickPayReservation(paymentHash: String) { - if (paymentHash.isBlank()) return - store.updateData { data -> - val reservation = data.quickPayReservations[paymentHash] ?: return@updateData data - val remaining = data.quickPayReservations - paymentHash - val spend = data.quickPaySpendFor(reservation.dayKey) - if (reservation.dayKey != spend.dayKey) { - return@updateData data.copy(quickPayReservations = remaining) - } - data.copy( - quickPaySpentCentsToday = (spend.spentCents - reservation.amountCents).coerceAtLeast(0L), - quickPayReservations = remaining, - ) - } - } - - suspend fun clearQuickPayReservation(paymentHash: String) { - if (paymentHash.isBlank()) return - store.updateData { - if (paymentHash !in it.quickPayReservations) return@updateData it - it.copy(quickPayReservations = it.quickPayReservations - paymentHash) - } - } - suspend fun reset() { store.updateData { AppCacheData() } Logger.info("Deleted all app cached data.") @@ -254,12 +177,6 @@ data class AppCacheData( fun invalidateReceiveLightningInvoice() = copy(bip21 = "", bolt11 = "", bolt11PaymentHash = "") fun invalidateReceiveOnchainAddress() = copy(bip21 = "", onchainAddress = "") - - fun quickPaySpendFor(dayKey: String): QuickPayDaySpend = when { - quickPaySpendDayKey.isEmpty() || dayKey > quickPaySpendDayKey -> QuickPayDaySpend(dayKey, 0L) - dayKey == quickPaySpendDayKey -> QuickPayDaySpend(dayKey, quickPaySpentCentsToday) - else -> QuickPayDaySpend(quickPaySpendDayKey, quickPaySpentCentsToday) - } } @Serializable @@ -272,9 +189,3 @@ data class QuickPayDaySpend( val dayKey: String, val spentCents: Long, ) - -fun quickPayCapCents(thresholdUsd: Int, multiplier: Int): Long = - thresholdUsd.toLong() * 100L * multiplier.toLong() - -fun quickPayReserveCents(convertedCents: Long, thresholdUsd: Int): Long = - minOf(convertedCents, thresholdUsd.toLong() * 100L) 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 000000000..16fb73778 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt @@ -0,0 +1,162 @@ +package to.bitkit.repositories + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import to.bitkit.data.AppCacheData +import to.bitkit.data.CacheStore +import to.bitkit.data.QuickPayDaySpend +import to.bitkit.data.QuickPaySpendReservation +import to.bitkit.data.SettingsStore +import to.bitkit.di.IoDispatcher +import to.bitkit.ext.quickPaySpendDayKey +import to.bitkit.ext.runSuspendCatching +import to.bitkit.models.USD +import to.bitkit.utils.Logger +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +@OptIn(ExperimentalTime::class) +@Singleton +class QuickPayRepo @Inject constructor( + private val cacheStore: CacheStore, + private val settingsStore: SettingsStore, + private val currencyRepo: CurrencyRepo, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val clock: Clock, +) { + companion object { + private const val TAG = "QuickPayRepo" + } + + suspend fun spentCentsToday(): Result = withContext(ioDispatcher) { + runSuspendCatching { + cacheStore.data.first().spendFor(currentDayKey()).spentCents + } + } + + 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) + val capCents = quickPayCapCents(settings.quickPayAmount, settings.quickPayDailyLimitMultiplier) + val spentCentsToday = cacheStore.data.first().spendFor(currentDayKey()).spentCents + if (spentCentsToday + reserveCents <= capCents) return@runSuspendCatching true + + Logger.info( + "Skipping QuickPay: daily spend '$spentCentsToday' + '$reserveCents' exceeds cap '$capCents'", + context = TAG, + ) + false + } + } + + suspend fun tryReserve(amountSats: ULong): Result = withContext(ioDispatcher) { + runSuspendCatching { + val settings = settingsStore.data.first() + val converted = requireNotNull( + currencyRepo.convertSatsToFiat(amountSats.toLong(), USD).getOrNull(), + ) { "Currency conversion failed" } + val amountCents = quickPayReserveCents(converted.toUsdCents(), settings.quickPayAmount) + val capCents = quickPayCapCents(settings.quickPayAmount, settings.quickPayDailyLimitMultiplier) + val dayKey = currentDayKey() + var reserved: QuickPaySpendReservation? = null + cacheStore.update { + val spend = it.spendFor(dayKey) + if (spend.spentCents + amountCents > capCents) return@update it + reserved = QuickPaySpendReservation(amountCents = amountCents, dayKey = spend.dayKey) + it.copy( + quickPaySpendDayKey = spend.dayKey, + quickPaySpentCentsToday = spend.spentCents + amountCents, + ) + } + reserved + } + } + + suspend fun remember( + paymentHash: String, + reservation: QuickPaySpendReservation, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + if (paymentHash.isBlank()) return@runSuspendCatching + cacheStore.update { + it.copy( + quickPayReservations = it.quickPayReservations + (paymentHash to reservation), + ) + } + } + } + + suspend fun reservation(paymentHash: String): Result = withContext(ioDispatcher) { + runSuspendCatching { + if (paymentHash.isBlank()) return@runSuspendCatching null + cacheStore.data.first().quickPayReservations[paymentHash] + } + } + + suspend fun release(paymentHash: String): Result = withContext(ioDispatcher) { + runSuspendCatching { + if (paymentHash.isBlank()) return@runSuspendCatching + cacheStore.update { data -> + val reservation = data.quickPayReservations[paymentHash] ?: return@update data + val remaining = data.quickPayReservations - paymentHash + val spend = data.spendFor(reservation.dayKey) + if (reservation.dayKey != spend.dayKey) { + return@update data.copy(quickPayReservations = remaining) + } + data.copy( + quickPaySpentCentsToday = (spend.spentCents - reservation.amountCents).coerceAtLeast(0L), + quickPayReservations = remaining, + ) + } + } + } + + suspend fun releaseUnbound(reservation: QuickPaySpendReservation): Result = withContext(ioDispatcher) { + runSuspendCatching { + cacheStore.update { + if (reservation.dayKey != it.quickPaySpendDayKey) return@update it + it.copy( + quickPaySpentCentsToday = (it.quickPaySpentCentsToday - reservation.amountCents).coerceAtLeast(0L), + ) + } + } + } + + suspend fun clear(paymentHash: String): Result = withContext(ioDispatcher) { + runSuspendCatching { + if (paymentHash.isBlank()) return@runSuspendCatching + cacheStore.update { + if (paymentHash !in it.quickPayReservations) return@update it + it.copy(quickPayReservations = it.quickPayReservations - paymentHash) + } + } + } + + private fun currentDayKey(): String = quickPaySpendDayKey(clock) +} + +fun quickPayCapCents(thresholdUsd: Int, multiplier: Int): Long = + thresholdUsd.toLong() * 100L * multiplier.toLong() + +fun quickPayReserveCents(convertedCents: Long, thresholdUsd: Int): Long = + minOf(convertedCents, thresholdUsd.toLong() * 100L) + +private fun AppCacheData.spendFor(dayKey: String): QuickPayDaySpend = when { + quickPaySpendDayKey.isEmpty() || dayKey > quickPaySpendDayKey -> QuickPayDaySpend(dayKey, 0L) + dayKey == quickPaySpendDayKey -> QuickPayDaySpend(dayKey, quickPaySpentCentsToday) + else -> QuickPayDaySpend(quickPaySpendDayKey, quickPaySpentCentsToday) +} diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index e366e1ab8..c74183f5c 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -74,11 +74,8 @@ import org.lightningdevkit.ldknode.Txid import to.bitkit.BuildConfig import to.bitkit.R import to.bitkit.data.CacheStore -import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain -import to.bitkit.data.quickPayCapCents -import to.bitkit.data.quickPayReserveCents import to.bitkit.data.resetPin import to.bitkit.di.BgDispatcher import to.bitkit.domain.commands.NotifyChannelReady @@ -100,7 +97,6 @@ import to.bitkit.ext.maxSendableSat import to.bitkit.ext.maxWithdrawableSat import to.bitkit.ext.minSendableSat import to.bitkit.ext.minWithdrawableSat -import to.bitkit.ext.quickPaySpendDayKey import to.bitkit.ext.rawId import to.bitkit.ext.removeSpaces import to.bitkit.ext.runSuspendCatching @@ -161,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 @@ -225,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, @@ -1165,7 +1163,7 @@ class AppViewModel @Inject constructor( activityRepo.handlePaymentEvent(paymentHash) if (pendingPaymentRepo.isPending(paymentHash)) { clearPendingContactPaymentContext(paymentHash) - cacheStore.releaseQuickPayReservation(paymentHash) + quickPayRepo.release(paymentHash) pendingPaymentRepo.resolve(PendingPaymentResolution.Failure(paymentHash, event.reason)) if (_currentSheet.value !is Sheet.Send || !pendingPaymentRepo.isActive(paymentHash)) { notifyPendingPaymentFailed() @@ -1242,8 +1240,8 @@ class AppViewModel @Inject constructor( activityRepo.handlePaymentEvent(paymentHash) if (pendingPaymentRepo.isPending(paymentHash)) { syncContactForActivity(paymentHash) - val isQuickPay = cacheStore.quickPayReservation(paymentHash) != null - cacheStore.clearQuickPayReservation(paymentHash) + val isQuickPay = quickPayRepo.reservation(paymentHash).getOrNull() != null + quickPayRepo.clear(paymentHash) val amountWithFeeSats = if (isQuickPay) { activityRepo.findActivityByPaymentId( paymentHashOrTxId = paymentHash, @@ -2715,31 +2713,7 @@ class AppViewModel @Inject constructor( private suspend fun canApplyQuickPay(amountSats: ULong): Boolean { if (hasActiveContactPaymentContext()) return false - - val settings = settingsStore.data.first() - if (!settings.isQuickPayEnabled || amountSats == 0uL) return false - - return isWithinQuickPayThreshold(amountSats, settings) && isWithinQuickPayDailyCap(amountSats, settings) - } - - private suspend fun isWithinQuickPayThreshold(amountSats: ULong, settings: SettingsData): Boolean { - val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), USD).getOrNull() - ?: return false - return amountSats <= quickPayAmountSats - } - - private suspend fun isWithinQuickPayDailyCap(amountSats: ULong, settings: SettingsData): Boolean { - val converted = currencyRepo.convertSatsToFiat(amountSats.toLong(), USD).getOrNull() ?: return false - val reserveCents = quickPayReserveCents(converted.toUsdCents(), settings.quickPayAmount) - val capCents = quickPayCapCents(settings.quickPayAmount, settings.quickPayDailyLimitMultiplier) - val spentCentsToday = cacheStore.quickPaySpentCentsForDay(quickPaySpendDayKey()) - if (spentCentsToday + reserveCents <= capCents) return true - - Logger.info( - "Skipping QuickPay: daily spend '$spentCentsToday' + '$reserveCents' exceeds cap '$capCents'", - context = TAG, - ) - return false + return quickPayRepo.canApply(amountSats).getOrDefault(false) } private fun resetAmountInput() { diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index f99c029ab..61c902ed5 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -7,32 +7,26 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first 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.R -import to.bitkit.data.CacheStore -import to.bitkit.data.SettingsStore -import to.bitkit.data.quickPayCapCents -import to.bitkit.data.quickPayReserveCents +import to.bitkit.data.QuickPaySpendReservation import to.bitkit.ext.WatchResult import to.bitkit.ext.callbackAmountMsats -import to.bitkit.ext.quickPaySpendDayKey import to.bitkit.ext.supportPaymentRequest import to.bitkit.ext.toCompactFailureType import to.bitkit.ext.toSendFailureDetails import to.bitkit.ext.watchUntil import to.bitkit.models.SendFailureDetails -import to.bitkit.models.USD import to.bitkit.models.msatFloorOf import to.bitkit.models.safe -import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.PaymentPendingException import to.bitkit.repositories.PendingPaymentRepo +import to.bitkit.repositories.QuickPayRepo import to.bitkit.utils.AppError import to.bitkit.utils.Logger import javax.inject.Inject @@ -42,9 +36,7 @@ class QuickPayViewModel @Inject constructor( @ApplicationContext private val context: Context, private val lightningRepo: LightningRepo, private val pendingPaymentRepo: PendingPaymentRepo, - private val currencyRepo: CurrencyRepo, - private val cacheStore: CacheStore, - private val settingsStore: SettingsStore, + private val quickPayRepo: QuickPayRepo, ) : ViewModel() { companion object { @@ -62,38 +54,29 @@ class QuickPayViewModel @Inject constructor( internal suspend fun payNow(data: QuickPayData) { val invoice = resolveQuickPayInvoice(data) ?: return - val dayKey = quickPaySpendDayKey() - val reservedCents = reserveSpend(invoice.displaySats, dayKey) ?: return + val reservation = reserveSpend(invoice.displaySats) ?: return - sendLightning(invoice, reservedCents, dayKey) + sendLightning(invoice, reservation) .onSuccess { onPaymentSuccess(it.paymentHash, invoice.displaySats, it.feePaidSats) } - .onFailure { onPaymentFailure(it, invoice, reservedCents, dayKey) } + .onFailure { onPaymentFailure(it, invoice, reservation) } } - private suspend fun reserveSpend(amountSats: ULong, dayKey: String): Long? { - val settings = settingsStore.data.first() - val converted = currencyRepo.convertSatsToFiat(amountSats.toLong(), USD).getOrNull() - if (converted == null) { + private suspend fun reserveSpend(amountSats: ULong): QuickPaySpendReservation? { + val reserved = quickPayRepo.tryReserve(amountSats).getOrElse { setError(QuickPayCurrencyConversionError()) return null } - val reserveCents = quickPayReserveCents(converted.toUsdCents(), settings.quickPayAmount) - val reserved = cacheStore.tryReserveQuickPaySpendCents( - amountCents = reserveCents, - dayKey = dayKey, - dailyCapCents = quickPayCapCents(settings.quickPayAmount, settings.quickPayDailyLimitMultiplier), - ) - if (!reserved) { + if (reserved == null) { Logger.info("Skipping QuickPay pay: daily spend reserve failed for '$amountSats'", context = TAG) _uiState.update { it.copy(result = QuickPayResult.FallBackToConfirm) } return null } - return reserveCents + return reserved } private suspend fun onPaymentSuccess(paymentHash: String, displaySats: ULong, feePaidSats: ULong) { Logger.info("QuickPay lightning payment successful") - cacheStore.clearQuickPayReservation(paymentHash) + quickPayRepo.clear(paymentHash) _uiState.update { it.copy( result = QuickPayResult.Success( @@ -107,8 +90,7 @@ class QuickPayViewModel @Inject constructor( private suspend fun onPaymentFailure( error: Throwable, invoice: QuickPayInvoice, - reservedCents: Long, - dayKey: String, + reservation: QuickPaySpendReservation, ) { if (error is PaymentPendingException) { Logger.info("QuickPay lightning payment pending", context = TAG) @@ -125,9 +107,9 @@ class QuickPayViewModel @Inject constructor( } Logger.error("QuickPay lightning payment failed", error, context = TAG) if (error is QuickPayPaymentFailedError) { - cacheStore.releaseQuickPayReservation(error.paymentHash) + quickPayRepo.release(error.paymentHash) } else { - cacheStore.releaseQuickPaySpendCents(reservedCents, dayKey) + quickPayRepo.releaseUnbound(reservation) } handleQuickPayFailure(error, invoice) } @@ -193,8 +175,7 @@ class QuickPayViewModel @Inject constructor( private suspend fun sendLightning( invoice: QuickPayInvoice, - reservedCents: Long, - dayKey: String, + reservation: QuickPaySpendReservation, ): Result { val hash = lightningRepo.payInvoice(bolt11 = invoice.bolt11, sats = invoice.amount) .onFailure { exception -> @@ -202,11 +183,7 @@ class QuickPayViewModel @Inject constructor( } .getOrDefault("") - cacheStore.rememberQuickPayReservation( - paymentHash = hash, - amountCents = reservedCents, - dayKey = dayKey, - ) + quickPayRepo.remember(paymentHash = hash, reservation = reservation) // Wait until matching payment event is received (with timeout for hold invoices) val result = lightningRepo.nodeEvents.watchUntil(LightningRepo.SEND_LN_TIMEOUT) { diff --git a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt index 4cc1ad77d..c7dd47f2a 100644 --- a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt +++ b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt @@ -16,7 +16,6 @@ import to.bitkit.ext.scopedActivityId import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals import kotlin.test.assertFalse -import kotlin.test.assertNull import kotlin.test.assertTrue @Config(application = Application::class, sdk = [34]) @@ -94,142 +93,6 @@ class CacheStoreTest : BaseUnitTest() { ) } - @Test - fun `quickPaySpentCentsForDay returns spend for matching day key`() = test { - sut.recordQuickPaySpendCents(amountCents = 3500L, dayKey = "2026-08-15") - - assertEquals(3500L, sut.quickPaySpentCentsForDay("2026-08-15")) - } - - @Test - fun `quickPaySpentCentsForDay returns zero for a later day key`() = test { - sut.recordQuickPaySpendCents(amountCents = 12_000L, dayKey = "2026-08-14") - - assertEquals(0L, sut.quickPaySpentCentsForDay("2026-08-15")) - } - - @Test - fun `quickPaySpentCentsForDay keeps spend on clock rollback`() = test { - sut.recordQuickPaySpendCents(amountCents = 12_000L, dayKey = "2026-08-15") - - assertEquals(12_000L, sut.quickPaySpentCentsForDay("2026-08-14")) - assertTrue( - sut.tryReserveQuickPaySpendCents( - amountCents = 1_000L, - dayKey = "2026-08-14", - dailyCapCents = 20_000L, - ), - ) - assertEquals(13_000L, sut.quickPaySpentCentsForDay("2026-08-14")) - assertEquals(13_000L, sut.quickPaySpentCentsForDay("2026-08-15")) - } - - @Test - fun `recordQuickPaySpendCents accumulates on the same day and resets on a new day`() = test { - sut.recordQuickPaySpendCents(amountCents = 2_000L, dayKey = "2026-08-15") - sut.recordQuickPaySpendCents(amountCents = 1_500L, dayKey = "2026-08-15") - assertEquals(3_500L, sut.quickPaySpentCentsForDay("2026-08-15")) - - sut.recordQuickPaySpendCents(amountCents = 4_000L, dayKey = "2026-08-16") - assertEquals(4_000L, sut.quickPaySpentCentsForDay("2026-08-16")) - assertEquals(4_000L, sut.quickPaySpentCentsForDay("2026-08-15")) - } - - @Test - fun `tryReserveQuickPaySpendCents reserves under the cap and rejects over it`() = test { - assertTrue( - sut.tryReserveQuickPaySpendCents( - amountCents = 10_000L, - dayKey = "2026-08-15", - dailyCapCents = 25_000L, - ), - ) - assertTrue( - sut.tryReserveQuickPaySpendCents( - amountCents = 10_000L, - dayKey = "2026-08-15", - dailyCapCents = 25_000L, - ), - ) - assertFalse( - sut.tryReserveQuickPaySpendCents( - amountCents = 10_000L, - dayKey = "2026-08-15", - dailyCapCents = 25_000L, - ), - ) - assertEquals(20_000L, sut.quickPaySpentCentsForDay("2026-08-15")) - } - - @Test - fun `releaseQuickPaySpendCents rolls back a reservation`() = test { - assertTrue( - sut.tryReserveQuickPaySpendCents( - amountCents = 5_000L, - dayKey = "2026-08-15", - dailyCapCents = 25_000L, - ), - ) - sut.releaseQuickPaySpendCents(amountCents = 5_000L, dayKey = "2026-08-15") - assertEquals(0L, sut.quickPaySpentCentsForDay("2026-08-15")) - } - - @Test - fun `releaseQuickPayReservation frees pending spend by payment hash`() = test { - assertTrue( - sut.tryReserveQuickPaySpendCents( - amountCents = 5_000L, - dayKey = "2026-08-15", - dailyCapCents = 25_000L, - ), - ) - sut.rememberQuickPayReservation(paymentHash = "abc", amountCents = 5_000L, dayKey = "2026-08-15") - - sut.releaseQuickPayReservation("abc") - - assertEquals(0L, sut.quickPaySpentCentsForDay("2026-08-15")) - } - - @Test - fun `clearQuickPayReservation keeps spend after success`() = test { - assertTrue( - sut.tryReserveQuickPaySpendCents( - amountCents = 5_000L, - dayKey = "2026-08-15", - dailyCapCents = 25_000L, - ), - ) - sut.rememberQuickPayReservation(paymentHash = "abc", amountCents = 5_000L, dayKey = "2026-08-15") - - sut.clearQuickPayReservation("abc") - - assertEquals(5_000L, sut.quickPaySpentCentsForDay("2026-08-15")) - } - - @Test - fun `releaseQuickPayReservation on a prior day does not decrement the new day`() = test { - assertTrue( - sut.tryReserveQuickPaySpendCents( - amountCents = 5_000L, - dayKey = "2026-08-15", - dailyCapCents = 25_000L, - ), - ) - sut.rememberQuickPayReservation(paymentHash = "old", amountCents = 5_000L, dayKey = "2026-08-15") - assertTrue( - sut.tryReserveQuickPaySpendCents( - amountCents = 4_000L, - dayKey = "2026-08-16", - dailyCapCents = 25_000L, - ), - ) - - sut.releaseQuickPayReservation("old") - - assertEquals(4_000L, sut.quickPaySpentCentsForDay("2026-08-16")) - assertNull(sut.quickPayReservation("old")) - } - @Test fun `old sat spend field is not read as cents`() { val data = json.decodeFromString( 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 000000000..10c845fa9 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt @@ -0,0 +1,206 @@ +package to.bitkit.repositories + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import to.bitkit.data.CacheStore +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.models.ConvertedAmount +import to.bitkit.models.USD +import to.bitkit.test.BaseUnitTest +import java.math.BigDecimal +import java.util.Locale +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +@OptIn(ExperimentalTime::class) +@Config(application = Application::class, sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class QuickPayRepoTest : BaseUnitTest() { + private val context = ApplicationProvider.getApplicationContext() + private val cacheStore = CacheStore(context) + private val settingsStore: SettingsStore = mock() + private val currencyRepo: CurrencyRepo = mock() + private val clock = MutableClock(Instant.parse("2026-08-15T12:00:00Z")) + private val settingsData = MutableStateFlow( + SettingsData(isQuickPayEnabled = true, quickPayAmount = 5, quickPayDailyLimitMultiplier = 5), + ) + + private lateinit var sut: QuickPayRepo + + @Before + fun setUp() = runBlocking { + cacheStore.reset() + whenever(settingsStore.data).thenReturn(settingsData) + 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 = QuickPayRepo( + cacheStore = cacheStore, + settingsStore = settingsStore, + currencyRepo = currencyRepo, + ioDispatcher = testDispatcher, + clock = clock, + ) + } + + @After + fun tearDown() = runBlocking { cacheStore.reset() } + + @Test + fun `spentCentsToday returns spend for matching day`() = test { + assertNotNull(sut.tryReserve(500u).getOrThrow()) + + assertEquals(250L, sut.spentCentsToday().getOrThrow()) + } + + @Test + fun `spentCentsToday returns zero for a later day`() = test { + assertNotNull(sut.tryReserve(500u).getOrThrow()) + clock.instant = Instant.parse("2026-08-16T12:00:00Z") + + assertEquals(0L, sut.spentCentsToday().getOrThrow()) + } + + @Test + fun `spentCentsToday keeps spend on clock rollback`() = test { + assertNotNull(sut.tryReserve(500u).getOrThrow()) + clock.instant = Instant.parse("2026-08-14T12:00:00Z") + + assertEquals(250L, sut.spentCentsToday().getOrThrow()) + assertNotNull(sut.tryReserve(200u).getOrThrow()) + assertEquals(350L, sut.spentCentsToday().getOrThrow()) + clock.instant = Instant.parse("2026-08-15T12:00:00Z") + assertEquals(350L, sut.spentCentsToday().getOrThrow()) + } + + @Test + fun `tryReserve accumulates on the same day and resets on a new day`() = test { + assertNotNull(sut.tryReserve(400u).getOrThrow()) + assertNotNull(sut.tryReserve(300u).getOrThrow()) + assertEquals(350L, sut.spentCentsToday().getOrThrow()) + + clock.instant = Instant.parse("2026-08-16T12:00:00Z") + assertNotNull(sut.tryReserve(800u).getOrThrow()) + assertEquals(400L, sut.spentCentsToday().getOrThrow()) + } + + @Test + fun `tryReserve reserves under the cap and rejects over it`() = test { + settingsData.value = settingsData.value.copy(quickPayDailyLimitMultiplier = 2) + assertNotNull(sut.tryReserve(1000u).getOrThrow()) + assertNotNull(sut.tryReserve(1000u).getOrThrow()) + assertNull(sut.tryReserve(1000u).getOrThrow()) + assertEquals(1000L, sut.spentCentsToday().getOrThrow()) + } + + @Test + fun `releaseUnbound rolls back a reservation`() = test { + val reserved = requireNotNull(sut.tryReserve(1000u).getOrThrow()) + + sut.releaseUnbound(reserved).getOrThrow() + + assertEquals(0L, sut.spentCentsToday().getOrThrow()) + } + + @Test + fun `releaseUnbound on a prior day does not decrement the new day`() = test { + val old = requireNotNull(sut.tryReserve(1000u).getOrThrow()) + clock.instant = Instant.parse("2026-08-16T12:00:00Z") + assertNotNull(sut.tryReserve(800u).getOrThrow()) + + sut.releaseUnbound(old).getOrThrow() + + assertEquals(400L, sut.spentCentsToday().getOrThrow()) + } + + @Test + fun `release frees pending spend by payment hash`() = test { + val reserved = requireNotNull(sut.tryReserve(1000u).getOrThrow()) + sut.remember("abc", reserved).getOrThrow() + + sut.release("abc").getOrThrow() + + assertEquals(0L, sut.spentCentsToday().getOrThrow()) + assertNull(sut.reservation("abc").getOrThrow()) + } + + @Test + fun `clear keeps spend after success`() = test { + val reserved = requireNotNull(sut.tryReserve(1000u).getOrThrow()) + sut.remember("abc", reserved).getOrThrow() + + sut.clear("abc").getOrThrow() + + assertEquals(500L, sut.spentCentsToday().getOrThrow()) + assertNull(sut.reservation("abc").getOrThrow()) + } + + @Test + fun `release on a prior day does not decrement the new day`() = test { + val old = requireNotNull(sut.tryReserve(1000u).getOrThrow()) + sut.remember("old", old).getOrThrow() + clock.instant = Instant.parse("2026-08-16T12:00:00Z") + assertNotNull(sut.tryReserve(800u).getOrThrow()) + + sut.release("old").getOrThrow() + + assertEquals(400L, sut.spentCentsToday().getOrThrow()) + assertNull(sut.reservation("old").getOrThrow()) + } + + @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.tryReserve(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()) + } +} + +@OptIn(ExperimentalTime::class) +private class MutableClock(var instant: Instant) : Clock { + override fun now(): Instant = instant +} diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 2f607aa5f..2c2565bff 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -65,7 +65,6 @@ import to.bitkit.domain.commands.NotifyChannelReadyHandler import to.bitkit.domain.commands.NotifyPaymentReceived import to.bitkit.domain.commands.NotifyPaymentReceivedHandler import to.bitkit.models.BalanceState -import to.bitkit.models.ConvertedAmount import to.bitkit.models.HwWalletReceivedTx import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection @@ -99,6 +98,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 @@ -124,10 +124,8 @@ import to.bitkit.usecases.FormatMoneyValue import to.bitkit.usecases.RefreshContactPaykitReceiversUseCase import to.bitkit.utils.AppError import to.bitkit.utils.timedsheets.TimedSheetManager -import java.math.BigDecimal import java.net.URLEncoder import java.nio.charset.StandardCharsets -import java.util.Locale import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull @@ -162,6 +160,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() @@ -230,10 +229,10 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(backupRepo.isRestoring).thenReturn(MutableStateFlow(false)) stubSettingsStore() whenever(cacheStore.data).thenReturn(flowOf(AppCacheData())) - whenever { cacheStore.quickPaySpentCentsForDay(any()) }.thenReturn(0L) - whenever { cacheStore.quickPayReservation(any()) }.thenReturn(null) - whenever { cacheStore.clearQuickPayReservation(any()) }.thenReturn(Unit) - whenever { cacheStore.releaseQuickPayReservation(any()) }.thenReturn(Unit) + whenever { quickPayRepo.canApply(org.mockito.kotlin.any()) }.thenReturn(Result.success(false)) + whenever { quickPayRepo.reservation(any()) }.thenReturn(Result.success(null)) + whenever { quickPayRepo.clear(any()) }.thenReturn(Result.success(Unit)) + whenever { quickPayRepo.release(any()) }.thenReturn(Result.success(Unit)) whenever { activityRepo.findActivityByPaymentId(any(), any(), any(), any()) } .thenReturn(Result.failure(Exception("activity not found"))) whenever(transferRepo.activeTransfers).thenReturn(flowOf(emptyList())) @@ -334,6 +333,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { notifyPaymentReceivedHandler = notifyPaymentReceivedHandler, notifyChannelReadyHandler = notifyChannelReadyHandler, cacheStore = cacheStore, + quickPayRepo = quickPayRepo, transferRepo = transferRepo, migrationService = migrationService, coreService = coreService, @@ -1728,7 +1728,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(pendingPaymentRepo).resolve(PendingPaymentResolution.Success(paymentHash)) verify(activityRepo).setContact(contactPublicKey = contactKey, forPaymentId = paymentHash) - verify(cacheStore).clearQuickPayReservation(paymentHash) + verify(quickPayRepo).clear(paymentHash) } @Test @@ -1754,7 +1754,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { reason = PaymentFailureReason.RETRIES_EXHAUSTED, ) ) - verify(cacheStore).releaseQuickPayReservation(paymentHash) + verify(quickPayRepo).release(paymentHash) assertNull(pendingContactPaymentContext(paymentHash)) } @@ -1763,7 +1763,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val paymentHash = "pending_confirm_hash" whenever(pendingPaymentRepo.isPending(paymentHash)).thenReturn(true) whenever(pendingPaymentRepo.isActive(paymentHash)).thenReturn(false) - whenever { cacheStore.quickPayReservation(paymentHash) }.thenReturn(null) + whenever { quickPayRepo.reservation(paymentHash) }.thenReturn(Result.success(null)) advanceUntilIdle() emitNodeEvent( @@ -1790,8 +1790,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val activity = mock { on { v1 } doReturn activityV1 } whenever(pendingPaymentRepo.isPending(paymentHash)).thenReturn(true) whenever(pendingPaymentRepo.isActive(paymentHash)).thenReturn(false) - whenever { cacheStore.quickPayReservation(paymentHash) }.thenReturn( - QuickPaySpendReservation(amountCents = 250L, dayKey = "2026-08-15"), + whenever { quickPayRepo.reservation(paymentHash) }.thenReturn( + Result.success(QuickPaySpendReservation(amountCents = 250L, dayKey = "2026-08-15")), ) whenever { activityRepo.findActivityByPaymentId(any(), any(), any(), any()) } .thenReturn(Result.success(activity)) @@ -1813,7 +1813,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { amountWithFeeSats = 510L, ), ) - verify(cacheStore).clearQuickPayReservation(paymentHash) + verify(quickPayRepo).clear(paymentHash) } @Test @@ -2235,7 +2235,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() @@ -2251,7 +2251,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `lightning scan uses QuickPay when enabled`() = test { val bolt11 = "lnbcrt1quickpay" - enableQuickPay(thresholdSats = 1000u) + enableQuickPay() stubLightningScan(bolt11 = bolt11, amountSats = 500u) sut.onScanResult(bolt11) @@ -2266,7 +2266,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @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, @@ -2284,7 +2284,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `lightning scan uses QuickPay when PIN is on without PIN for payments`() = test { val bolt11 = "lnbcrt1quickpayunlocked" - enableQuickPay(thresholdSats = 1000u) + enableQuickPay() settingsData.value = settingsData.value.copy(isPinEnabled = true) stubLightningScan(bolt11 = bolt11, amountSats = 500u) sut.setIsAuthenticated(true) @@ -2299,8 +2299,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `lightning scan skips QuickPay when daily spend cap is exceeded`() = test { val bolt11 = "lnbcrt1quickpaycap" - enableQuickPay(thresholdSats = 1000u, spentCentsToday = 2_300L) - settingsData.value = settingsData.value.copy(quickPayDailyLimitMultiplier = 5) + enableQuickPay(canApply = false) stubLightningScan(bolt11 = bolt11, amountSats = 500u) sut.setIsAuthenticated(true) @@ -2314,7 +2313,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @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, @@ -2607,7 +2606,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") @@ -3318,26 +3317,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() } - private fun enableQuickPay( - thresholdSats: ULong, - spentCentsToday: Long = 0L, - ) { + private fun enableQuickPay(canApply: Boolean = true) { settingsData.value = SettingsData(isQuickPayEnabled = true, quickPayAmount = 5) - whenever(currencyRepo.convertFiatToSats(5.0, "USD")).thenReturn(Result.success(thresholdSats)) - whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())).thenAnswer { invocation -> - val sats = invocation.getArgument(0) - val usd = 5.0 * sats.toDouble() / thresholdSats.toDouble() - ConvertedAmount( - value = BigDecimal.valueOf(usd), - formatted = usd.toString(), - symbol = "$", - currency = "USD", - flag = "", - sats = sats, - locale = Locale.US, - ) - } - whenever { cacheStore.quickPaySpentCentsForDay(any()) }.thenReturn(spentCentsToday) + whenever { quickPayRepo.canApply(org.mockito.kotlin.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 index d8726fd2d..4720d4534 100644 --- a/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt @@ -23,19 +23,13 @@ import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import to.bitkit.R -import to.bitkit.data.CacheStore -import to.bitkit.data.SettingsData -import to.bitkit.data.SettingsStore -import to.bitkit.ext.quickPaySpendDayKey -import to.bitkit.models.ConvertedAmount +import to.bitkit.data.QuickPaySpendReservation import to.bitkit.models.NodeLifecycleState -import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState import to.bitkit.repositories.PendingPaymentRepo +import to.bitkit.repositories.QuickPayRepo import to.bitkit.test.BaseUnitTest -import java.math.BigDecimal -import java.util.Locale import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertNull @@ -47,14 +41,10 @@ class QuickPayViewModelTest : BaseUnitTest() { private val context: Context = mock() private val lightningRepo: LightningRepo = mock() private val pendingPaymentRepo: PendingPaymentRepo = mock() - private val currencyRepo: CurrencyRepo = mock() - private val cacheStore: CacheStore = mock() - private val settingsStore: SettingsStore = mock() + private val quickPayRepo: QuickPayRepo = mock() private lateinit var nodeEvents: MutableSharedFlow - private val settingsData = MutableStateFlow( - SettingsData(isQuickPayEnabled = true, quickPayAmount = 5, quickPayDailyLimitMultiplier = 5), - ) + private val reserved = QuickPaySpendReservation(amountCents = 250L, dayKey = "2026-08-15") private lateinit var sut: QuickPayViewModel @@ -68,28 +58,16 @@ class QuickPayViewModelTest : BaseUnitTest() { MutableStateFlow(LightningState(nodeLifecycleState = NodeLifecycleState.Running)), ) whenever(lightningRepo.nodeEvents).thenReturn(nodeEvents) - whenever(settingsStore.data).thenReturn(settingsData) - 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, - ) - } - whenever { cacheStore.tryReserveQuickPaySpendCents(any(), any(), any()) }.thenReturn(true) + whenever { quickPayRepo.tryReserve(any()) }.thenReturn(Result.success(reserved)) + whenever { quickPayRepo.remember(any(), any()) }.thenReturn(Result.success(Unit)) + whenever { quickPayRepo.clear(any()) }.thenReturn(Result.success(Unit)) + whenever { quickPayRepo.release(any()) }.thenReturn(Result.success(Unit)) + whenever { quickPayRepo.releaseUnbound(any()) }.thenReturn(Result.success(Unit)) sut = QuickPayViewModel( context = context, lightningRepo = lightningRepo, pendingPaymentRepo = pendingPaymentRepo, - currencyRepo = currencyRepo, - cacheStore = cacheStore, - settingsStore = settingsStore, + quickPayRepo = quickPayRepo, ) } @@ -108,19 +86,11 @@ class QuickPayViewModelTest : BaseUnitTest() { sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) advanceUntilIdle() - val order = inOrder(cacheStore, lightningRepo) - order.verify(cacheStore).tryReserveQuickPaySpendCents( - amountCents = 250L, - dayKey = quickPaySpendDayKey(), - dailyCapCents = 2_500L, - ) + val order = inOrder(quickPayRepo, lightningRepo) + order.verify(quickPayRepo).tryReserve(500u) order.verify(lightningRepo).payInvoice(bolt11 = "lnbcrt1test", sats = null) - order.verify(cacheStore).rememberQuickPayReservation( - paymentHash = "hash1", - amountCents = 250L, - dayKey = quickPaySpendDayKey(), - ) - order.verify(cacheStore).clearQuickPayReservation("hash1") + order.verify(quickPayRepo).remember("hash1", reserved) + order.verify(quickPayRepo).clear("hash1") verify(pendingPaymentRepo, never()).track(any()) val success = assertIs(sut.uiState.value.result) assertEquals("hash1", success.paymentHash) @@ -136,12 +106,8 @@ class QuickPayViewModelTest : BaseUnitTest() { advanceTimeBy(LightningRepo.SEND_LN_TIMEOUT.inWholeMilliseconds + 1) advanceUntilIdle() - val order = inOrder(cacheStore, pendingPaymentRepo) - order.verify(cacheStore).rememberQuickPayReservation( - paymentHash = "hash1", - amountCents = 250L, - dayKey = quickPaySpendDayKey(), - ) + val order = inOrder(quickPayRepo, pendingPaymentRepo) + order.verify(quickPayRepo).remember("hash1", reserved) order.verify(pendingPaymentRepo).track("hash1") val pending = assertIs(sut.uiState.value.result) assertEquals("hash1", pending.paymentHash) @@ -155,8 +121,8 @@ class QuickPayViewModelTest : BaseUnitTest() { sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) advanceUntilIdle() - verify(cacheStore).releaseQuickPaySpendCents(250L, quickPaySpendDayKey()) - verify(cacheStore, never()).rememberQuickPayReservation(any(), any(), any()) + verify(quickPayRepo).releaseUnbound(reserved) + verify(quickPayRepo, never()).remember(any(), any()) verify(pendingPaymentRepo, never()).track(any()) assertIs(sut.uiState.value.result) } @@ -175,14 +141,14 @@ class QuickPayViewModelTest : BaseUnitTest() { sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) advanceUntilIdle() - verify(cacheStore).rememberQuickPayReservation("hash1", 250L, quickPaySpendDayKey()) - verify(cacheStore).releaseQuickPayReservation("hash1") + verify(quickPayRepo).remember("hash1", reserved) + verify(quickPayRepo).release("hash1") assertIs(sut.uiState.value.result) } @Test fun `reserve failure emits FallBackToConfirm`() = test { - whenever { cacheStore.tryReserveQuickPaySpendCents(any(), any(), any()) }.thenReturn(false) + whenever { quickPayRepo.tryReserve(any()) }.thenReturn(Result.success(null)) sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) advanceUntilIdle() From 1bb04980996b8132918e24926390f46084c0e092 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 19 Aug 2026 23:50:54 +0200 Subject: [PATCH 21/71] chore: cleanup imports --- .../java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 2c2565bff..d0604fb40 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -229,7 +229,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(backupRepo.isRestoring).thenReturn(MutableStateFlow(false)) stubSettingsStore() whenever(cacheStore.data).thenReturn(flowOf(AppCacheData())) - whenever { quickPayRepo.canApply(org.mockito.kotlin.any()) }.thenReturn(Result.success(false)) + whenever { quickPayRepo.canApply(any()) }.thenReturn(Result.success(false)) whenever { quickPayRepo.reservation(any()) }.thenReturn(Result.success(null)) whenever { quickPayRepo.clear(any()) }.thenReturn(Result.success(Unit)) whenever { quickPayRepo.release(any()) }.thenReturn(Result.success(Unit)) @@ -3319,7 +3319,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private fun enableQuickPay(canApply: Boolean = true) { settingsData.value = SettingsData(isQuickPayEnabled = true, quickPayAmount = 5) - whenever { quickPayRepo.canApply(org.mockito.kotlin.any()) }.thenReturn(Result.success(canApply)) + whenever { quickPayRepo.canApply(any()) }.thenReturn(Result.success(canApply)) } private suspend fun stubLightningScan(bolt11: String, amountSats: ULong) { From 37017dfd96aa23e28702875245e23cd5c2b17295 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 20 Aug 2026 00:00:08 +0200 Subject: [PATCH 22/71] fix: keep quickpay events and spend in sync --- .../to/bitkit/repositories/QuickPayRepo.kt | 13 +-- .../java/to/bitkit/ui/sheets/SendSheet.kt | 1 + .../java/to/bitkit/viewmodels/AppViewModel.kt | 1 + .../to/bitkit/viewmodels/QuickPayViewModel.kt | 68 +++++++------- app/src/main/res/values/strings.xml | 1 - .../bitkit/repositories/QuickPayRepoTest.kt | 11 +++ .../viewmodels/AppViewModelSendFlowTest.kt | 55 ++++++++++++ .../viewmodels/QuickPayViewModelTest.kt | 90 +++++++++++++++++-- 8 files changed, 198 insertions(+), 42 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt index 16fb73778..8802ddbba 100644 --- a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt @@ -12,6 +12,7 @@ import to.bitkit.di.IoDispatcher import to.bitkit.ext.quickPaySpendDayKey import to.bitkit.ext.runSuspendCatching import to.bitkit.models.USD +import to.bitkit.utils.AppError import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton @@ -66,9 +67,9 @@ class QuickPayRepo @Inject constructor( suspend fun tryReserve(amountSats: ULong): Result = withContext(ioDispatcher) { runSuspendCatching { val settings = settingsStore.data.first() - val converted = requireNotNull( - currencyRepo.convertSatsToFiat(amountSats.toLong(), USD).getOrNull(), - ) { "Currency conversion failed" } + val converted = currencyRepo.convertSatsToFiat(amountSats.toLong(), USD).getOrElse { + throw QuickPayConversionError() + } val amountCents = quickPayReserveCents(converted.toUsdCents(), settings.quickPayAmount) val capCents = quickPayCapCents(settings.quickPayAmount, settings.quickPayDailyLimitMultiplier) val dayKey = currentDayKey() @@ -149,12 +150,14 @@ class QuickPayRepo @Inject constructor( private fun currentDayKey(): String = quickPaySpendDayKey(clock) } -fun quickPayCapCents(thresholdUsd: Int, multiplier: Int): Long = +private fun quickPayCapCents(thresholdUsd: Int, multiplier: Int): Long = thresholdUsd.toLong() * 100L * multiplier.toLong() -fun quickPayReserveCents(convertedCents: Long, thresholdUsd: Int): Long = +private fun quickPayReserveCents(convertedCents: Long, thresholdUsd: Int): Long = minOf(convertedCents, thresholdUsd.toLong() * 100L) +class QuickPayConversionError : AppError("Currency conversion failed") + private fun AppCacheData.spendFor(dayKey: String): QuickPayDaySpend = when { quickPaySpendDayKey.isEmpty() || dayKey > quickPaySpendDayKey -> QuickPayDaySpend(dayKey, 0L) dayKey == quickPaySpendDayKey -> QuickPayDaySpend(dayKey, quickPaySpentCentsToday) 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 af475af15..15753d15a 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -352,6 +352,7 @@ fun SendSheet( } }, onFallBackToConfirm = { + appViewModel.resetQuickPay() navController.navigateTo(SendRoute.Confirm) { popUpTo { inclusive = true } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index c74183f5c..120d77e7a 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1176,6 +1176,7 @@ class AppViewModel @Inject constructor( } 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 diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index 61c902ed5..c2195faca 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -5,6 +5,9 @@ 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.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update @@ -26,6 +29,7 @@ import to.bitkit.models.safe import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.PaymentPendingException import to.bitkit.repositories.PendingPaymentRepo +import to.bitkit.repositories.QuickPayConversionError import to.bitkit.repositories.QuickPayRepo import to.bitkit.utils.AppError import to.bitkit.utils.Logger @@ -47,9 +51,11 @@ class QuickPayViewModel @Inject constructor( val uiState = _uiState.asStateFlow() val lightningState = lightningRepo.lightningState + private var payJob: Job? = null fun pay(data: QuickPayData) { - viewModelScope.launch { payNow(data) } + if (payJob?.isActive == true || _uiState.value.result != null) return + payJob = viewModelScope.launch { payNow(data) } } internal suspend fun payNow(data: QuickPayData) { @@ -63,7 +69,7 @@ class QuickPayViewModel @Inject constructor( private suspend fun reserveSpend(amountSats: ULong): QuickPaySpendReservation? { val reserved = quickPayRepo.tryReserve(amountSats).getOrElse { - setError(QuickPayCurrencyConversionError()) + setError(it) return null } if (reserved == null) { @@ -75,7 +81,7 @@ class QuickPayViewModel @Inject constructor( } private suspend fun onPaymentSuccess(paymentHash: String, displaySats: ULong, feePaidSats: ULong) { - Logger.info("QuickPay lightning payment successful") + Logger.info("QuickPay lightning payment successful", context = TAG) quickPayRepo.clear(paymentHash) _uiState.update { it.copy( @@ -116,7 +122,7 @@ class QuickPayViewModel @Inject constructor( private fun setError(error: Throwable, paymentRequest: String? = null) { val localizedMessage = when (error) { - is QuickPayCurrencyConversionError -> { + is QuickPayConversionError -> { context.getString(R.string.wallet__send_quickpay__currency_conversion) } else -> null @@ -183,37 +189,39 @@ class QuickPayViewModel @Inject constructor( } .getOrDefault("") - quickPayRepo.remember(paymentHash = hash, reservation = reservation) - - // 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( - SettledQuickPayPayment( - paymentHash = hash, - feePaidSats = msatFloorOf(it.feePaidMsat ?: 0u), + return coroutineScope { + val settled = async { + lightningRepo.nodeEvents.watchUntil(LightningRepo.SEND_LN_TIMEOUT) { + when (it) { + is Event.PaymentSuccessful if it.paymentHash == hash -> WatchResult.Complete( + Result.success( + SettledQuickPayPayment( + paymentHash = hash, + feePaidSats = msatFloorOf(it.feePaidMsat ?: 0u), + ) + ) ) - ) - ) - is Event.PaymentFailed if it.paymentHash == hash -> WatchResult.Complete( - Result.failure( - QuickPayPaymentFailedError( - paymentHash = hash, - reason = it.reason, - paymentRequest = invoice.bolt11, + is Event.PaymentFailed if it.paymentHash == hash -> WatchResult.Complete( + Result.failure( + QuickPayPaymentFailedError( + paymentHash = hash, + reason = it.reason, + paymentRequest = invoice.bolt11, + ) + ) ) - ) - ) - else -> WatchResult.Continue() + else -> WatchResult.Continue() + } + } } + quickPayRepo.remember(paymentHash = hash, reservation = reservation) + val result = settled.await() + if (result != null) return@coroutineScope result + pendingPaymentRepo.track(hash) + Result.failure(PaymentPendingException(hash)) } - if (result != null) return result - - pendingPaymentRepo.track(hash) - return Result.failure(PaymentPendingException(hash)) } } @@ -222,8 +230,6 @@ private data class SettledQuickPayPayment( val feePaidSats: ULong, ) -private class QuickPayCurrencyConversionError : AppError("Currency conversion failed") - sealed class QuickPayResult { data class Success( val paymentHash: String, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 53c6d360e..57a288533 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1253,7 +1253,6 @@ This payment is taking a bit longer than expected. You can continue using Bitkit. Payment Pending Currency conversion failed - Daily QuickPay limit reached QuickPay Paying\n<accent>invoice...</accent> Confirm diff --git a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt index 10c845fa9..467ebf2ba 100644 --- a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt @@ -198,6 +198,17 @@ class QuickPayRepoTest : BaseUnitTest() { assertFalse(sut.canApply(500u).getOrThrow()) } + + @Test + fun `tryReserve fails with conversion error when rates are unavailable`() = test { + whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())).thenAnswer { + throw QuickPayConversionError() + } + + val result = sut.tryReserve(500u) + + assertTrue(result.exceptionOrNull() is QuickPayConversionError) + } } @OptIn(ExperimentalTime::class) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index d0604fb40..0d576306b 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -1858,6 +1858,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") diff --git a/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt index 4720d4534..b59f107d0 100644 --- a/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt @@ -1,10 +1,12 @@ package to.bitkit.viewmodels import android.content.Context +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.setMain @@ -15,9 +17,11 @@ import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.PaymentFailureReason import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.inOrder 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 @@ -28,6 +32,7 @@ import to.bitkit.models.NodeLifecycleState import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState import to.bitkit.repositories.PendingPaymentRepo +import to.bitkit.repositories.QuickPayConversionError import to.bitkit.repositories.QuickPayRepo import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals @@ -51,7 +56,7 @@ class QuickPayViewModelTest : BaseUnitTest() { @Before fun setUp() { Dispatchers.setMain(testDispatcher) - nodeEvents = MutableSharedFlow(replay = 1, extraBufferCapacity = 8) + nodeEvents = MutableSharedFlow(replay = 0, extraBufferCapacity = 8) whenever(context.getString(any())).thenReturn("error") whenever(context.getString(R.string.wallet__send_quickpay__currency_conversion)).thenReturn("conversion") whenever(lightningRepo.lightningState).thenReturn( @@ -74,6 +79,8 @@ class QuickPayViewModelTest : BaseUnitTest() { @Test fun `happy path reserves before payInvoice and clears reservation on success`() = test { whenever { lightningRepo.payInvoice(any(), anyOrNull()) }.thenReturn(Result.success("hash1")) + + launch { sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) } nodeEvents.emit( Event.PaymentSuccessful( paymentId = "pid", @@ -82,8 +89,6 @@ class QuickPayViewModelTest : BaseUnitTest() { feePaidMsat = 1_000uL, ), ) - - sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) advanceUntilIdle() val order = inOrder(quickPayRepo, lightningRepo) @@ -130,6 +135,8 @@ class QuickPayViewModelTest : BaseUnitTest() { @Test fun `payment failed after submit releases hash keyed reservation`() = test { whenever { lightningRepo.payInvoice(any(), anyOrNull()) }.thenReturn(Result.success("hash1")) + + launch { sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) } nodeEvents.emit( Event.PaymentFailed( paymentId = "pid", @@ -137,8 +144,6 @@ class QuickPayViewModelTest : BaseUnitTest() { reason = PaymentFailureReason.ROUTE_NOT_FOUND, ), ) - - sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) advanceUntilIdle() verify(quickPayRepo).remember("hash1", reserved) @@ -157,4 +162,79 @@ class QuickPayViewModelTest : BaseUnitTest() { verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) assertNull(sut.uiState.value.result.takeIf { it is QuickPayResult.Error }) } + + @Test + fun `fast fail during remember is collected and released`() = test { + val allowRemember = CompletableDeferred() + whenever { lightningRepo.payInvoice(any(), anyOrNull()) }.thenReturn(Result.success("hash1")) + whenever { quickPayRepo.remember(any(), any()) }.doSuspendableAnswer { + allowRemember.await() + Result.success(Unit) + } + + launch { sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) } + nodeEvents.emit( + Event.PaymentFailed( + paymentId = "pid", + paymentHash = "hash1", + reason = PaymentFailureReason.ROUTE_NOT_FOUND, + ), + ) + allowRemember.complete(Unit) + advanceUntilIdle() + + verify(quickPayRepo).release("hash1") + verify(pendingPaymentRepo, never()).track(any()) + assertIs(sut.uiState.value.result) + } + + @Test + fun `pay ignores re-entry while in flight`() = test { + val allowPay = CompletableDeferred() + whenever { lightningRepo.payInvoice(any(), anyOrNull()) }.doSuspendableAnswer { + allowPay.await() + Result.success("hash1") + } + val data = QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test") + + sut.pay(data) + sut.pay(data) + verify(quickPayRepo, times(1)).tryReserve(any()) + verify(lightningRepo, times(1)).payInvoice(any(), anyOrNull()) + + allowPay.complete(Unit) + nodeEvents.emit( + Event.PaymentSuccessful( + paymentId = "pid", + paymentHash = "hash1", + paymentPreimage = "preimage", + feePaidMsat = 1_000uL, + ), + ) + advanceUntilIdle() + } + + @Test + fun `conversion failure uses currency conversion message`() = test { + whenever { quickPayRepo.tryReserve(any()) }.thenReturn(Result.failure(QuickPayConversionError())) + + sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) + advanceUntilIdle() + + val error = assertIs(sut.uiState.value.result) + assertEquals("conversion", error.failure.message) + verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) + } + + @Test + fun `non conversion reserve failure is not the currency string`() = test { + whenever { quickPayRepo.tryReserve(any()) }.thenReturn(Result.failure(IllegalStateException("disk"))) + + sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) + advanceUntilIdle() + + val error = assertIs(sut.uiState.value.result) + assertEquals("disk", error.failure.message) + verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) + } } From ffe28d9b2ede1a477a90413040d14f1a0ababfd2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 20 Aug 2026 00:36:18 +0200 Subject: [PATCH 23/71] fix: release spend without pending gate --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 6 +-- .../viewmodels/AppViewModelSendFlowTest.kt | 40 +++++++++++++++++++ .../viewmodels/QuickPayViewModelTest.kt | 6 ++- 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 120d77e7a..a7adba0e7 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1161,9 +1161,9 @@ class AppViewModel @Inject constructor( private suspend fun handlePaymentFailed(event: Event.PaymentFailed) { event.paymentHash?.let { paymentHash -> activityRepo.handlePaymentEvent(paymentHash) + quickPayRepo.release(paymentHash) if (pendingPaymentRepo.isPending(paymentHash)) { clearPendingContactPaymentContext(paymentHash) - quickPayRepo.release(paymentHash) pendingPaymentRepo.resolve(PendingPaymentResolution.Failure(paymentHash, event.reason)) if (_currentSheet.value !is Sheet.Send || !pendingPaymentRepo.isActive(paymentHash)) { notifyPendingPaymentFailed() @@ -1239,10 +1239,10 @@ class AppViewModel @Inject constructor( private suspend fun handlePaymentSuccessful(event: Event.PaymentSuccessful) { event.paymentHash.let { paymentHash -> activityRepo.handlePaymentEvent(paymentHash) + val isQuickPay = quickPayRepo.reservation(paymentHash).getOrNull() != null + quickPayRepo.clear(paymentHash) if (pendingPaymentRepo.isPending(paymentHash)) { syncContactForActivity(paymentHash) - val isQuickPay = quickPayRepo.reservation(paymentHash).getOrNull() != null - quickPayRepo.clear(paymentHash) val amountWithFeeSats = if (isQuickPay) { activityRepo.findActivityByPaymentId( paymentHashOrTxId = paymentHash, diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 0d576306b..45d181977 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -1758,6 +1758,46 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertNull(pendingContactPaymentContext(paymentHash)) } + @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).release(paymentHash) + 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.reservation(paymentHash) }.thenReturn( + Result.success(QuickPaySpendReservation(amountCents = 250L, dayKey = "2026-08-15")), + ) + + emitNodeEvent( + Event.PaymentSuccessful( + paymentId = "payment_id", + paymentHash = paymentHash, + paymentPreimage = "preimage", + feePaidMsat = 10uL, + ), + ) + advanceUntilIdle() + + verify(quickPayRepo).clear(paymentHash) + verify(pendingPaymentRepo, never()).resolve(any()) + } + @Test fun `pending confirm lightning success keeps invoice amount`() = test { val paymentHash = "pending_confirm_hash" diff --git a/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt index b59f107d0..ea2cbe0f6 100644 --- a/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.setMain import org.junit.Before import org.junit.Test @@ -38,6 +39,7 @@ import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertNull +import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) @@ -56,7 +58,7 @@ class QuickPayViewModelTest : BaseUnitTest() { @Before fun setUp() { Dispatchers.setMain(testDispatcher) - nodeEvents = MutableSharedFlow(replay = 0, extraBufferCapacity = 8) + nodeEvents = MutableSharedFlow(replay = 0, extraBufferCapacity = 0) whenever(context.getString(any())).thenReturn("error") whenever(context.getString(R.string.wallet__send_quickpay__currency_conversion)).thenReturn("conversion") whenever(lightningRepo.lightningState).thenReturn( @@ -173,6 +175,8 @@ class QuickPayViewModelTest : BaseUnitTest() { } launch { sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) } + runCurrent() + assertTrue(nodeEvents.subscriptionCount.value > 0) nodeEvents.emit( Event.PaymentFailed( paymentId = "pid", From 68cba13dff0b5ddba0428617ed9ff795aa871bb2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 20 Aug 2026 00:46:04 +0200 Subject: [PATCH 24/71] test: drop unshipped sat spend decode --- app/src/test/java/to/bitkit/data/CacheStoreTest.kt | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt index c7dd47f2a..915f35d3b 100644 --- a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt +++ b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt @@ -11,7 +11,6 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config -import to.bitkit.di.json import to.bitkit.ext.scopedActivityId import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals @@ -92,14 +91,4 @@ class CacheStoreTest : BaseUnitTest() { sut.data.first().deletedActivities, ) } - - @Test - fun `old sat spend field is not read as cents`() { - val data = json.decodeFromString( - """{"quickPaySpendDayKey":"2026-08-15","quickPaySpentSatsToday":20000}""", - ) - - assertEquals(0L, data.quickPaySpentCentsToday) - assertEquals("2026-08-15", data.quickPaySpendDayKey) - } } From ef74df45891139290f09fbb5ec203a7dc20e2cc4 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 20 Aug 2026 00:50:21 +0200 Subject: [PATCH 25/71] test: drop duplicate setmain in quickpay --- .../test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt index ea2cbe0f6..f17628e7d 100644 --- a/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt @@ -2,7 +2,6 @@ package to.bitkit.viewmodels import android.content.Context import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -10,7 +9,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.setMain import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -57,7 +55,6 @@ class QuickPayViewModelTest : BaseUnitTest() { @Before fun setUp() { - Dispatchers.setMain(testDispatcher) nodeEvents = MutableSharedFlow(replay = 0, extraBufferCapacity = 0) whenever(context.getString(any())).thenReturn("error") whenever(context.getString(R.string.wallet__send_quickpay__currency_conversion)).thenReturn("conversion") From 5c21813dcddf559c5516b77a53654fbdb0e7b6e6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 20 Aug 2026 00:55:45 +0200 Subject: [PATCH 26/71] refactor: inline quickpay day key --- app/src/main/java/to/bitkit/ext/DateTime.kt | 6 ------ app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt | 6 ++++-- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/ext/DateTime.kt b/app/src/main/java/to/bitkit/ext/DateTime.kt index 145b2908d..8358e3871 100644 --- a/app/src/main/java/to/bitkit/ext/DateTime.kt +++ b/app/src/main/java/to/bitkit/ext/DateTime.kt @@ -39,12 +39,6 @@ fun nowMillis(clock: Clock = Clock.System): Long = clock.now().toEpochMillisecon @OptIn(ExperimentalTime::class) fun Clock.nowMs(): Long = now().toEpochMilliseconds() -@OptIn(ExperimentalTime::class) -fun quickPaySpendDayKey( - clock: Clock = Clock.System, - timeZone: TimeZone = TimeZone.currentSystemDefault(), -): String = clock.now().toLocalDateTime(timeZone).date.toString() - fun nowTimestamp(): Instant = Instant.now().truncatedTo(ChronoUnit.SECONDS) fun dateTimeFormatterOf( diff --git a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt index 8802ddbba..0974d51a3 100644 --- a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt @@ -3,13 +3,14 @@ package to.bitkit.repositories import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime import to.bitkit.data.AppCacheData import to.bitkit.data.CacheStore import to.bitkit.data.QuickPayDaySpend import to.bitkit.data.QuickPaySpendReservation import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher -import to.bitkit.ext.quickPaySpendDayKey import to.bitkit.ext.runSuspendCatching import to.bitkit.models.USD import to.bitkit.utils.AppError @@ -147,7 +148,8 @@ class QuickPayRepo @Inject constructor( } } - private fun currentDayKey(): String = quickPaySpendDayKey(clock) + private fun currentDayKey(): String = + clock.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.toString() } private fun quickPayCapCents(thresholdUsd: Int, multiplier: Int): Long = From b0f56d577a21692e73eb67cca368a0bd41481190 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 20 Aug 2026 01:00:34 +0200 Subject: [PATCH 27/71] refactor: move quickpay types to repo --- app/src/main/java/to/bitkit/data/CacheStore.kt | 12 +----------- .../java/to/bitkit/repositories/QuickPayRepo.kt | 14 ++++++++++++-- .../java/to/bitkit/viewmodels/QuickPayViewModel.kt | 2 +- .../bitkit/viewmodels/AppViewModelSendFlowTest.kt | 2 +- .../to/bitkit/viewmodels/QuickPayViewModelTest.kt | 2 +- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index 6b1b348de..7332bf4cb 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.QuickPaySpendReservation import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton @@ -178,14 +179,3 @@ data class AppCacheData( fun invalidateReceiveOnchainAddress() = copy(bip21 = "", onchainAddress = "") } - -@Serializable -data class QuickPaySpendReservation( - val amountCents: Long, - val dayKey: String, -) - -data class QuickPayDaySpend( - val dayKey: String, - val spentCents: Long, -) diff --git a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt index 0974d51a3..6caa76b85 100644 --- a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt @@ -5,10 +5,9 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime +import kotlinx.serialization.Serializable import to.bitkit.data.AppCacheData import to.bitkit.data.CacheStore -import to.bitkit.data.QuickPayDaySpend -import to.bitkit.data.QuickPaySpendReservation import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher import to.bitkit.ext.runSuspendCatching @@ -152,6 +151,17 @@ class QuickPayRepo @Inject constructor( clock.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.toString() } +@Serializable +data class QuickPaySpendReservation( + val amountCents: Long, + val dayKey: String, +) + +private data class QuickPayDaySpend( + val dayKey: String, + val spentCents: Long, +) + private fun quickPayCapCents(thresholdUsd: Int, multiplier: Int): Long = thresholdUsd.toLong() * 100L * multiplier.toLong() diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index c2195faca..336f49e43 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -16,7 +16,7 @@ import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.PaymentFailureReason import org.lightningdevkit.ldknode.PaymentId import to.bitkit.R -import to.bitkit.data.QuickPaySpendReservation +import to.bitkit.repositories.QuickPaySpendReservation import to.bitkit.ext.WatchResult import to.bitkit.ext.callbackAmountMsats import to.bitkit.ext.supportPaymentRequest diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 45d181977..48757b6f5 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -57,7 +57,7 @@ import to.bitkit.CurrentActivity import to.bitkit.R import to.bitkit.data.AppCacheData import to.bitkit.data.CacheStore -import to.bitkit.data.QuickPaySpendReservation +import to.bitkit.repositories.QuickPaySpendReservation import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain diff --git a/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt index f17628e7d..fe0abf224 100644 --- a/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt @@ -26,7 +26,7 @@ import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import to.bitkit.R -import to.bitkit.data.QuickPaySpendReservation +import to.bitkit.repositories.QuickPaySpendReservation import to.bitkit.models.NodeLifecycleState import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState From d46ab0554c56d2a5db9dc3e1f6ab2dadd20d6623 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 20 Aug 2026 01:12:22 +0200 Subject: [PATCH 28/71] refactor: drop experimentaltime opt-in --- app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt | 2 -- app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt | 3 --- 2 files changed, 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt index 6caa76b85..3b250c442 100644 --- a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt @@ -17,9 +17,7 @@ import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Clock -import kotlin.time.ExperimentalTime -@OptIn(ExperimentalTime::class) @Singleton class QuickPayRepo @Inject constructor( private val cacheStore: CacheStore, diff --git a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt index 467ebf2ba..e485b30d4 100644 --- a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt @@ -29,10 +29,8 @@ import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Clock -import kotlin.time.ExperimentalTime import kotlin.time.Instant -@OptIn(ExperimentalTime::class) @Config(application = Application::class, sdk = [34]) @RunWith(RobolectricTestRunner::class) class QuickPayRepoTest : BaseUnitTest() { @@ -211,7 +209,6 @@ class QuickPayRepoTest : BaseUnitTest() { } } -@OptIn(ExperimentalTime::class) private class MutableClock(var instant: Instant) : Clock { override fun now(): Instant = instant } From 0f59bc6a16e128986779e130a67cb6d74870792d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 20 Aug 2026 01:16:38 +0200 Subject: [PATCH 29/71] chore: fix quickpay import order --- app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt | 2 +- .../test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt | 2 +- app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index 336f49e43..ce2733e88 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -16,7 +16,6 @@ import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.PaymentFailureReason import org.lightningdevkit.ldknode.PaymentId import to.bitkit.R -import to.bitkit.repositories.QuickPaySpendReservation import to.bitkit.ext.WatchResult import to.bitkit.ext.callbackAmountMsats import to.bitkit.ext.supportPaymentRequest @@ -31,6 +30,7 @@ import to.bitkit.repositories.PaymentPendingException import to.bitkit.repositories.PendingPaymentRepo import to.bitkit.repositories.QuickPayConversionError import to.bitkit.repositories.QuickPayRepo +import to.bitkit.repositories.QuickPaySpendReservation import to.bitkit.utils.AppError import to.bitkit.utils.Logger import javax.inject.Inject diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 48757b6f5..f75933a79 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -57,7 +57,6 @@ import to.bitkit.CurrentActivity import to.bitkit.R import to.bitkit.data.AppCacheData import to.bitkit.data.CacheStore -import to.bitkit.repositories.QuickPaySpendReservation import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain @@ -99,6 +98,7 @@ import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitPaymentResult import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.repositories.QuickPayRepo +import to.bitkit.repositories.QuickPaySpendReservation import to.bitkit.repositories.SamRockRepo import to.bitkit.repositories.SettledReceiveAddress import to.bitkit.repositories.SettledReceiveInvoice diff --git a/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt index fe0abf224..7fbe78035 100644 --- a/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt @@ -26,13 +26,13 @@ import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import to.bitkit.R -import to.bitkit.repositories.QuickPaySpendReservation import to.bitkit.models.NodeLifecycleState import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState import to.bitkit.repositories.PendingPaymentRepo import to.bitkit.repositories.QuickPayConversionError import to.bitkit.repositories.QuickPayRepo +import to.bitkit.repositories.QuickPaySpendReservation import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals import kotlin.test.assertIs From 7f18c0ba33824c257aa6e12c0daf1ea59a2ce6a3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 20 Aug 2026 20:12:01 +0200 Subject: [PATCH 30/71] fix: count sub-cent quickpay as 1 cent --- .../bitkit/repositories/PendingPaymentRepo.kt | 8 +++++ .../to/bitkit/repositories/QuickPayRepo.kt | 15 +++++--- .../wallets/send/SendPendingViewModel.kt | 3 ++ .../java/to/bitkit/viewmodels/AppViewModel.kt | 26 +++++++++----- .../to/bitkit/viewmodels/QuickPayViewModel.kt | 2 +- .../repositories/PendingPaymentRepoTest.kt | 20 +++++++++++ .../bitkit/repositories/QuickPayRepoTest.kt | 36 +++++++++++++++++++ .../wallets/send/SendPendingViewModelTest.kt | 14 ++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 4 +-- .../viewmodels/QuickPayViewModelTest.kt | 4 +-- 10 files changed, 115 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PendingPaymentRepo.kt b/app/src/main/java/to/bitkit/repositories/PendingPaymentRepo.kt index b2ac5e038..183b9b393 100644 --- a/app/src/main/java/to/bitkit/repositories/PendingPaymentRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PendingPaymentRepo.kt @@ -21,6 +21,7 @@ class PendingPaymentRepo @Inject constructor() { private val _resolution = MutableSharedFlow(extraBufferCapacity = 1) val resolution = _resolution.asSharedFlow() + private val lastResolutions = MutableStateFlow>(emptyMap()) fun track(paymentHash: String) { _state.update { it.copy(pendingPayments = it.pendingPayments + paymentHash) } @@ -30,9 +31,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 diff --git a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt index 3b250c442..166b458b7 100644 --- a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt @@ -49,7 +49,7 @@ class QuickPayRepo @Inject constructor( val converted = currencyRepo.convertSatsToFiat(amountSats.toLong(), USD).getOrNull() ?: return@runSuspendCatching false - val reserveCents = quickPayReserveCents(converted.toUsdCents(), settings.quickPayAmount) + val reserveCents = quickPayReserveCents(converted.toUsdCents(), settings.quickPayAmount, amountSats) val capCents = quickPayCapCents(settings.quickPayAmount, settings.quickPayDailyLimitMultiplier) val spentCentsToday = cacheStore.data.first().spendFor(currentDayKey()).spentCents if (spentCentsToday + reserveCents <= capCents) return@runSuspendCatching true @@ -68,7 +68,7 @@ class QuickPayRepo @Inject constructor( val converted = currencyRepo.convertSatsToFiat(amountSats.toLong(), USD).getOrElse { throw QuickPayConversionError() } - val amountCents = quickPayReserveCents(converted.toUsdCents(), settings.quickPayAmount) + val amountCents = quickPayReserveCents(converted.toUsdCents(), settings.quickPayAmount, amountSats) val capCents = quickPayCapCents(settings.quickPayAmount, settings.quickPayDailyLimitMultiplier) val dayKey = currentDayKey() var reserved: QuickPaySpendReservation? = null @@ -163,8 +163,15 @@ private data class QuickPayDaySpend( private fun quickPayCapCents(thresholdUsd: Int, multiplier: Int): Long = thresholdUsd.toLong() * 100L * multiplier.toLong() -private fun quickPayReserveCents(convertedCents: Long, thresholdUsd: Int): Long = - minOf(convertedCents, thresholdUsd.toLong() * 100L) +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) +} class QuickPayConversionError : AppError("Currency conversion failed") 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 98643c683..f06e05d81 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) } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index a7adba0e7..411423ea1 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1165,7 +1165,7 @@ class AppViewModel @Inject constructor( 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 @@ -1175,6 +1175,11 @@ 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() @@ -1244,12 +1249,17 @@ class AppViewModel @Inject constructor( if (pendingPaymentRepo.isPending(paymentHash)) { syncContactForActivity(paymentHash) val amountWithFeeSats = if (isQuickPay) { - activityRepo.findActivityByPaymentId( - paymentHashOrTxId = paymentHash, - type = ActivityFilter.LIGHTNING, - txType = PaymentType.SENT, - retry = true, - ).getOrNull()?.totalValue()?.toLong() + val principal = ( + activityRepo.findActivityByPaymentId( + paymentHashOrTxId = paymentHash, + type = ActivityFilter.LIGHTNING, + txType = PaymentType.SENT, + retry = true, + ).getOrNull() as? Activity.Lightning + )?.v1?.value + principal?.let { + (it.safe() + msatFloorOf(event.feePaidMsat ?: 0u).safe()).toLong() + } } else { null } @@ -1259,7 +1269,7 @@ class AppViewModel @Inject constructor( amountWithFeeSats = amountWithFeeSats, ), ) - if (_currentSheet.value !is Sheet.Send || !pendingPaymentRepo.isActive(paymentHash)) { + if (shouldNotifyPendingResolution(paymentHash)) { notifyPendingPaymentSucceeded() } return diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index ce2733e88..1b26182a7 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -217,9 +217,9 @@ class QuickPayViewModel @Inject constructor( } } quickPayRepo.remember(paymentHash = hash, reservation = reservation) + pendingPaymentRepo.track(hash) val result = settled.await() if (result != null) return@coroutineScope result - pendingPaymentRepo.track(hash) Result.failure(PaymentPendingException(hash)) } } diff --git a/app/src/test/java/to/bitkit/repositories/PendingPaymentRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PendingPaymentRepoTest.kt index 301c83ce7..dee4e8373 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,25 @@ class PendingPaymentRepoTest : BaseUnitTest() { assertFalse(sut.isActive("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 `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 index e485b30d4..f2460b10f 100644 --- a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt @@ -197,6 +197,42 @@ class QuickPayRepoTest : BaseUnitTest() { assertFalse(sut.canApply(500u).getOrThrow()) } + @Test + fun `zero cent conversion at a full cap is rejected`() = test { + stubZeroCentConversion(7L) + repeat(5) { assertNotNull(sut.tryReserve(1000u).getOrThrow()) } + + assertFalse(sut.canApply(7u).getOrThrow()) + assertNull(sut.tryReserve(7u).getOrThrow()) + } + + @Test + fun `zero cent conversion on a fresh day reserves one cent`() = test { + stubZeroCentConversion(7L) + + val reserved = requireNotNull(sut.tryReserve(7u).getOrThrow()) + + assertEquals(1L, reserved.amountCents) + assertEquals(1L, sut.spentCentsToday().getOrThrow()) + assertTrue(sut.canApply(7u).getOrThrow()) + } + + 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, + ) + } + } + @Test fun `tryReserve fails with conversion error when rates are unavailable`() = test { whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())).thenAnswer { 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 38ca3ec21..4c4c576d0 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) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index f75933a79..58731bb58 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -1825,7 +1825,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val paymentHash = "pending_quickpay_hash" val activityV1 = mock { on { value } doReturn 500u - on { fee } doReturn 10u + on { fee } doReturn 0u } val activity = mock { on { v1 } doReturn activityV1 } whenever(pendingPaymentRepo.isPending(paymentHash)).thenReturn(true) @@ -1842,7 +1842,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { paymentId = "payment_id", paymentHash = paymentHash, paymentPreimage = "preimage", - feePaidMsat = 10uL, + feePaidMsat = 10_000uL, ), ) advanceUntilIdle() diff --git a/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt index 7fbe78035..5f4ef1dd3 100644 --- a/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt @@ -95,7 +95,7 @@ class QuickPayViewModelTest : BaseUnitTest() { order.verify(lightningRepo).payInvoice(bolt11 = "lnbcrt1test", sats = null) order.verify(quickPayRepo).remember("hash1", reserved) order.verify(quickPayRepo).clear("hash1") - verify(pendingPaymentRepo, never()).track(any()) + verify(pendingPaymentRepo).track("hash1") val success = assertIs(sut.uiState.value.result) assertEquals("hash1", success.paymentHash) assertEquals(501L, success.amountWithFee) @@ -185,7 +185,7 @@ class QuickPayViewModelTest : BaseUnitTest() { advanceUntilIdle() verify(quickPayRepo).release("hash1") - verify(pendingPaymentRepo, never()).track(any()) + verify(pendingPaymentRepo).track("hash1") assertIs(sut.uiState.value.result) } From ee5a67a0dfefc2fe11181be8b3a744f0049b676b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 20 Aug 2026 21:17:02 +0200 Subject: [PATCH 31/71] fix: clear stale quickpay pending state --- .../to/bitkit/repositories/PendingPaymentRepo.kt | 1 + .../ui/screens/wallets/send/SendPendingViewModel.kt | 1 + .../main/java/to/bitkit/viewmodels/AppViewModel.kt | 1 + .../bitkit/repositories/PendingPaymentRepoTest.kt | 9 +++++++++ .../wallets/send/SendPendingViewModelTest.kt | 1 + .../bitkit/viewmodels/AppViewModelSendFlowTest.kt | 13 +++++++++++++ 6 files changed, 26 insertions(+) diff --git a/app/src/main/java/to/bitkit/repositories/PendingPaymentRepo.kt b/app/src/main/java/to/bitkit/repositories/PendingPaymentRepo.kt index 183b9b393..3477e31ee 100644 --- a/app/src/main/java/to/bitkit/repositories/PendingPaymentRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PendingPaymentRepo.kt @@ -24,6 +24,7 @@ class PendingPaymentRepo @Inject constructor() { private val lastResolutions = MutableStateFlow>(emptyMap()) fun track(paymentHash: String) { + lastResolutions.update { it - paymentHash } _state.update { it.copy(pendingPayments = it.pendingPayments + paymentHash) } } 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 f06e05d81..301d622f0 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 @@ -68,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/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 411423ea1..d602576fe 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -3481,6 +3481,7 @@ class AppViewModel @Inject constructor( fun hideSheet() = hideSheet(shouldFlushDeferredScan = true) private fun hideSheet(shouldFlushDeferredScan: Boolean) { + if (_currentSheet.value is Sheet.Send) resetQuickPay() scanResultHandler = null receiveSheetContext = null sheetTransitionJob?.cancel() diff --git a/app/src/test/java/to/bitkit/repositories/PendingPaymentRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PendingPaymentRepoTest.kt index dee4e8373..a93670911 100644 --- a/app/src/test/java/to/bitkit/repositories/PendingPaymentRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PendingPaymentRepoTest.kt @@ -108,6 +108,15 @@ class PendingPaymentRepoTest : BaseUnitTest() { 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")) 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 4c4c576d0..34e86502d 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 @@ -109,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 58731bb58..7d4adc835 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -2358,6 +2358,19 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.Send(SendRoute.QuickPay), sut.currentSheet.value) } + @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" From eed24c8c7b862b06e6f12717699dc051804d14cf Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 20 Aug 2026 22:25:04 +0200 Subject: [PATCH 32/71] refactor: drop unused rtl and spend helper --- .../to/bitkit/repositories/QuickPayRepo.kt | 6 -- .../java/to/bitkit/ui/components/Slider.kt | 58 ++++++------------- .../bitkit/repositories/QuickPayRepoTest.kt | 41 +++++-------- .../ui/components/StepSliderMappingTest.kt | 25 -------- 4 files changed, 30 insertions(+), 100 deletions(-) delete mode 100644 app/src/test/java/to/bitkit/ui/components/StepSliderMappingTest.kt diff --git a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt index 166b458b7..583722622 100644 --- a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt @@ -30,12 +30,6 @@ class QuickPayRepo @Inject constructor( private const val TAG = "QuickPayRepo" } - suspend fun spentCentsToday(): Result = withContext(ioDispatcher) { - runSuspendCatching { - cacheStore.data.first().spendFor(currentDayKey()).spentCents - } - } - suspend fun canApply(amountSats: ULong): Result = withContext(ioDispatcher) { runSuspendCatching { val settings = settingsStore.data.first() 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 33f797d9b..c2c4d77e1 100644 --- a/app/src/main/java/to/bitkit/ui/components/Slider.kt +++ b/app/src/main/java/to/bitkit/ui/components/Slider.kt @@ -38,7 +38,6 @@ 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.platform.LocalLayoutDirection import androidx.compose.ui.semantics.ProgressBarRangeInfo import androidx.compose.ui.semantics.progressBarRangeInfo import androidx.compose.ui.semantics.semantics @@ -48,7 +47,6 @@ 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.LayoutDirection import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -60,15 +58,6 @@ import kotlin.math.roundToInt private const val KNOB_SIZE_DP = 32 -internal fun sliderPointerToLogical(x: Float, width: Float, isRtl: Boolean): Float = - if (isRtl) width - x else x - -internal fun sliderLogicalToVisual(x: Float, width: Float, isRtl: Boolean): Float = - if (isRtl) width - x else x - -internal fun sliderDragDeltaToLogical(deltaX: Float, isRtl: Boolean): Float = - if (isRtl) -deltaX else deltaX - /** Horizontal inset so the knob stays clear of the screen edge and its system back-gesture zone. */ private const val SLIDER_EDGE_INSET_DP = 16 private const val TRACK_HEIGHT_DP = 8 @@ -86,7 +75,6 @@ fun Slider( formatLabel: (Int) -> String = { "$$it" }, ) { val density = LocalDensity.current - val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl val coroutineScope = rememberCoroutineScope() val knobPosition = remember { Animatable(0f) } var isDragging by remember { mutableStateOf(false) } @@ -133,7 +121,6 @@ fun Slider( steps.indices.map { index -> (index.toFloat() / numSteps) * sliderWidth } } val knobX = if (isDragging) knobPosition.value else stepPositions.getOrElse(valueIndex) { 0f } - val visualKnobX = sliderLogicalToVisual(knobX, sliderWidth, isRtl) fun findClosestStep(currentPosition: Float): Pair { if (stepPositions.isEmpty()) return 0f to 0 @@ -164,10 +151,9 @@ fun Slider( modifier = Modifier .fillMaxWidth() .height(KNOB_SIZE_DP.dp) - .pointerInput(stepPositions, steps, isRtl, sliderWidth) { + .pointerInput(stepPositions, steps, sliderWidth) { detectTapGestures { offset -> - val logicalX = sliderPointerToLogical(offset.x, sliderWidth, isRtl) - val (closestStep, closestIndex) = findClosestStep(logicalX) + val (closestStep, closestIndex) = findClosestStep(offset.x) coroutineScope.launch { knobPosition.snapTo(settledXState.value) isDragging = true @@ -193,12 +179,10 @@ fun Slider( ) if (knobX > 0f) { - val activeLeft = if (isRtl) visualKnobX else 0f - val activeWidth = if (isRtl) size.width - visualKnobX else visualKnobX drawRoundRect( color = Colors.Green, - topLeft = Offset(activeLeft, trackY - trackHeight / 2), - size = Size(activeWidth, trackHeight), + topLeft = Offset(0f, trackY - trackHeight / 2), + size = Size(knobX, trackHeight), cornerRadius = CornerRadius(cornerRadius), ) } @@ -208,10 +192,9 @@ fun Slider( val markerRadius = density.run { 2.5.dp.toPx() } stepPositions.forEach { position -> - val visualX = sliderLogicalToVisual(position, size.width, isRtl) drawRoundRect( color = Colors.White, - topLeft = Offset(visualX - markerWidth / 2, trackY - markerHeight / 2), + topLeft = Offset(position - markerWidth / 2, trackY - markerHeight / 2), size = Size(markerWidth, markerHeight), cornerRadius = CornerRadius(markerRadius), ) @@ -222,12 +205,12 @@ fun Slider( modifier = Modifier .offset { IntOffset( - x = (visualKnobX - with(density) { KNOB_SIZE_DP.dp.toPx() / 2 }).roundToInt(), + x = (knobX - with(density) { KNOB_SIZE_DP.dp.toPx() / 2 }).roundToInt(), y = 0, ) } .size(KNOB_SIZE_DP.dp) - .pointerInput(stepPositions, steps, sliderWidth, isRtl) { + .pointerInput(stepPositions, steps, sliderWidth) { detectHorizontalDragGestures( onDragStart = { coroutineScope.launch { @@ -254,9 +237,8 @@ fun Slider( }, ) { _, dragAmount -> coroutineScope.launch { - val newPosition = ( - knobPosition.value + sliderDragDeltaToLogical(dragAmount, isRtl) - ).coerceIn(0f, sliderWidth) + val newPosition = (knobPosition.value + dragAmount) + .coerceIn(0f, sliderWidth) knobPosition.snapTo(newPosition) } } @@ -366,7 +348,6 @@ fun AmountSlider( modifier: Modifier = Modifier, ) { val density = LocalDensity.current - val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl val coroutineScope = rememberCoroutineScope() var sliderWidth by remember { mutableIntStateOf(0) } @@ -389,7 +370,6 @@ fun AmountSlider( } val widthPx = sliderWidth.toFloat() - val visualKnobX = sliderLogicalToVisual(knobPosition.value, widthPx, isRtl) Box( modifier = modifier @@ -403,10 +383,9 @@ fun AmountSlider( modifier = Modifier .fillMaxWidth() .height(KNOB_SIZE_DP.dp) - .pointerInput(sliderWidth, min, max, isRtl) { + .pointerInput(sliderWidth, min, max) { detectTapGestures { offset -> - val logicalX = sliderPointerToLogical(offset.x, widthPx, isRtl) - val v = valueFor(logicalX) + val v = valueFor(offset.x) coroutineScope.launch { knobPosition.snapTo(fractionFor(v) * widthPx) } onValueChange(v) } @@ -425,12 +404,10 @@ fun AmountSlider( ) // Active track if (knobPosition.value > 0) { - val activeLeft = if (isRtl) visualKnobX else 0f - val activeWidth = if (isRtl) size.width - visualKnobX else visualKnobX drawRoundRect( color = Colors.Green, - topLeft = Offset(activeLeft, trackY - trackHeight / 2), - size = Size(activeWidth, trackHeight), + topLeft = Offset(0f, trackY - trackHeight / 2), + size = Size(knobPosition.value, trackHeight), cornerRadius = CornerRadius(cornerRadius), ) } @@ -441,17 +418,16 @@ fun AmountSlider( modifier = Modifier .offset { IntOffset( - x = (visualKnobX - with(density) { KNOB_SIZE_DP.dp.toPx() / 2 }).roundToInt(), + x = (knobPosition.value - with(density) { KNOB_SIZE_DP.dp.toPx() / 2 }).roundToInt(), y = 0, ) } .size(KNOB_SIZE_DP.dp) - .pointerInput(sliderWidth, min, max, isRtl) { + .pointerInput(sliderWidth, min, max) { detectHorizontalDragGestures { _, dragAmount -> coroutineScope.launch { - val newPosition = ( - knobPosition.value + sliderDragDeltaToLogical(dragAmount, isRtl) - ).coerceIn(0f, widthPx) + val newPosition = (knobPosition.value + dragAmount) + .coerceIn(0f, widthPx) knobPosition.snapTo(newPosition) onValueChange(valueFor(newPosition)) } diff --git a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt index f2460b10f..6be381086 100644 --- a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt @@ -4,6 +4,7 @@ import android.app.Application import android.content.Context import androidx.test.core.app.ApplicationProvider import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Before @@ -76,41 +77,25 @@ class QuickPayRepoTest : BaseUnitTest() { fun tearDown() = runBlocking { cacheStore.reset() } @Test - fun `spentCentsToday returns spend for matching day`() = test { - assertNotNull(sut.tryReserve(500u).getOrThrow()) - - assertEquals(250L, sut.spentCentsToday().getOrThrow()) - } - - @Test - fun `spentCentsToday returns zero for a later day`() = test { - assertNotNull(sut.tryReserve(500u).getOrThrow()) - clock.instant = Instant.parse("2026-08-16T12:00:00Z") - - assertEquals(0L, sut.spentCentsToday().getOrThrow()) - } - - @Test - fun `spentCentsToday keeps spend on clock rollback`() = test { + fun `tryReserve on clock rollback keeps existing spend`() = test { assertNotNull(sut.tryReserve(500u).getOrThrow()) clock.instant = Instant.parse("2026-08-14T12:00:00Z") - assertEquals(250L, sut.spentCentsToday().getOrThrow()) assertNotNull(sut.tryReserve(200u).getOrThrow()) - assertEquals(350L, sut.spentCentsToday().getOrThrow()) + assertEquals(350L, cacheStore.data.first().quickPaySpentCentsToday) clock.instant = Instant.parse("2026-08-15T12:00:00Z") - assertEquals(350L, sut.spentCentsToday().getOrThrow()) + assertEquals(350L, cacheStore.data.first().quickPaySpentCentsToday) } @Test fun `tryReserve accumulates on the same day and resets on a new day`() = test { assertNotNull(sut.tryReserve(400u).getOrThrow()) assertNotNull(sut.tryReserve(300u).getOrThrow()) - assertEquals(350L, sut.spentCentsToday().getOrThrow()) + assertEquals(350L, cacheStore.data.first().quickPaySpentCentsToday) clock.instant = Instant.parse("2026-08-16T12:00:00Z") assertNotNull(sut.tryReserve(800u).getOrThrow()) - assertEquals(400L, sut.spentCentsToday().getOrThrow()) + assertEquals(400L, cacheStore.data.first().quickPaySpentCentsToday) } @Test @@ -119,7 +104,7 @@ class QuickPayRepoTest : BaseUnitTest() { assertNotNull(sut.tryReserve(1000u).getOrThrow()) assertNotNull(sut.tryReserve(1000u).getOrThrow()) assertNull(sut.tryReserve(1000u).getOrThrow()) - assertEquals(1000L, sut.spentCentsToday().getOrThrow()) + assertEquals(1000L, cacheStore.data.first().quickPaySpentCentsToday) } @Test @@ -128,7 +113,7 @@ class QuickPayRepoTest : BaseUnitTest() { sut.releaseUnbound(reserved).getOrThrow() - assertEquals(0L, sut.spentCentsToday().getOrThrow()) + assertEquals(0L, cacheStore.data.first().quickPaySpentCentsToday) } @Test @@ -139,7 +124,7 @@ class QuickPayRepoTest : BaseUnitTest() { sut.releaseUnbound(old).getOrThrow() - assertEquals(400L, sut.spentCentsToday().getOrThrow()) + assertEquals(400L, cacheStore.data.first().quickPaySpentCentsToday) } @Test @@ -149,7 +134,7 @@ class QuickPayRepoTest : BaseUnitTest() { sut.release("abc").getOrThrow() - assertEquals(0L, sut.spentCentsToday().getOrThrow()) + assertEquals(0L, cacheStore.data.first().quickPaySpentCentsToday) assertNull(sut.reservation("abc").getOrThrow()) } @@ -160,7 +145,7 @@ class QuickPayRepoTest : BaseUnitTest() { sut.clear("abc").getOrThrow() - assertEquals(500L, sut.spentCentsToday().getOrThrow()) + assertEquals(500L, cacheStore.data.first().quickPaySpentCentsToday) assertNull(sut.reservation("abc").getOrThrow()) } @@ -173,7 +158,7 @@ class QuickPayRepoTest : BaseUnitTest() { sut.release("old").getOrThrow() - assertEquals(400L, sut.spentCentsToday().getOrThrow()) + assertEquals(400L, cacheStore.data.first().quickPaySpentCentsToday) assertNull(sut.reservation("old").getOrThrow()) } @@ -213,7 +198,7 @@ class QuickPayRepoTest : BaseUnitTest() { val reserved = requireNotNull(sut.tryReserve(7u).getOrThrow()) assertEquals(1L, reserved.amountCents) - assertEquals(1L, sut.spentCentsToday().getOrThrow()) + assertEquals(1L, cacheStore.data.first().quickPaySpentCentsToday) assertTrue(sut.canApply(7u).getOrThrow()) } diff --git a/app/src/test/java/to/bitkit/ui/components/StepSliderMappingTest.kt b/app/src/test/java/to/bitkit/ui/components/StepSliderMappingTest.kt deleted file mode 100644 index 0e19a67be..000000000 --- a/app/src/test/java/to/bitkit/ui/components/StepSliderMappingTest.kt +++ /dev/null @@ -1,25 +0,0 @@ -package to.bitkit.ui.components - -import kotlin.test.Test -import kotlin.test.assertEquals - -class StepSliderMappingTest { - - @Test - fun `pointer mapping mirrors physical x in rtl`() { - assertEquals(20f, sliderPointerToLogical(x = 80f, width = 100f, isRtl = true)) - assertEquals(80f, sliderPointerToLogical(x = 80f, width = 100f, isRtl = false)) - } - - @Test - fun `visual mapping mirrors logical x in rtl`() { - assertEquals(20f, sliderLogicalToVisual(x = 80f, width = 100f, isRtl = true)) - assertEquals(80f, sliderLogicalToVisual(x = 80f, width = 100f, isRtl = false)) - } - - @Test - fun `drag delta flips in rtl so thumb follows the finger`() { - assertEquals(-12f, sliderDragDeltaToLogical(deltaX = 12f, isRtl = true)) - assertEquals(12f, sliderDragDeltaToLogical(deltaX = 12f, isRtl = false)) - } -} From d0229ad479d8c82aa9188c86a73f09cf81dbedb2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 20 Aug 2026 23:43:47 +0200 Subject: [PATCH 33/71] refactor: drop unused quickpay error param --- app/src/main/java/to/bitkit/ui/components/Slider.kt | 4 ++-- app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt | 5 ++--- .../test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt | 2 -- 3 files changed, 4 insertions(+), 7 deletions(-) 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 c2c4d77e1..67f6bafff 100644 --- a/app/src/main/java/to/bitkit/ui/components/Slider.kt +++ b/app/src/main/java/to/bitkit/ui/components/Slider.kt @@ -488,7 +488,7 @@ private fun PreviewUnitStops() { private fun PreviewVerticalStack() { AppThemeSurface { var dollars by remember { mutableIntStateOf(1) } - var times by remember { mutableIntStateOf(1) } + var times by remember { mutableIntStateOf(5) } Column(modifier = Modifier.padding(32.dp)) { Slider( value = dollars, @@ -497,7 +497,7 @@ private fun PreviewVerticalStack() { ) VerticalSpacer(32.dp) Slider( - value = 50, + value = times, steps = persistentListOf(1, 3, 5, 10, 50), onValueChange = { times = it }, formatLabel = { "$it×" }, diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index 1b26182a7..ebfd7a53b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -120,7 +120,7 @@ class QuickPayViewModel @Inject constructor( handleQuickPayFailure(error, invoice) } - private fun setError(error: Throwable, paymentRequest: String? = null) { + private fun setError(error: Throwable) { val localizedMessage = when (error) { is QuickPayConversionError -> { context.getString(R.string.wallet__send_quickpay__currency_conversion) @@ -132,10 +132,9 @@ class QuickPayViewModel @Inject constructor( message = localizedMessage, failureType = error.toCompactFailureType(), resetRoutingCachesOnRetry = false, - paymentRequest = paymentRequest, ) } else { - error.toSendFailureDetails(context, paymentRequest) + error.toSendFailureDetails(context) } _uiState.update { it.copy(result = QuickPayResult.Error(failure)) } } diff --git a/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt index 5f4ef1dd3..aaac67fee 100644 --- a/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt @@ -36,7 +36,6 @@ import to.bitkit.repositories.QuickPaySpendReservation import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals import kotlin.test.assertIs -import kotlin.test.assertNull import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) @@ -159,7 +158,6 @@ class QuickPayViewModelTest : BaseUnitTest() { assertEquals(QuickPayResult.FallBackToConfirm, sut.uiState.value.result) verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) - assertNull(sut.uiState.value.result.takeIf { it is QuickPayResult.Error }) } @Test From 737585553954c32c7d78bad2616ac5fc3356d395 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 22 Aug 2026 02:22:40 +0200 Subject: [PATCH 34/71] fix: settle quickpay spend through one ledger --- .../main/java/to/bitkit/data/CacheStore.kt | 6 +- .../to/bitkit/repositories/LightningRepo.kt | 11 + .../to/bitkit/repositories/QuickPayRepo.kt | 848 ++++++++++++++++-- .../wallets/send/SendQuickPayScreen.kt | 11 +- app/src/main/java/to/bitkit/utils/Errors.kt | 10 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 86 +- .../to/bitkit/viewmodels/QuickPayViewModel.kt | 245 +---- .../bitkit/repositories/QuickPayRepoTest.kt | 279 ++++-- .../viewmodels/AppViewModelSendFlowTest.kt | 69 +- .../viewmodels/QuickPayViewModelTest.kt | 200 ++--- .../resources/quickpay/android-ledger.json | 23 + .../test/resources/quickpay/ios-ledger.json | 14 + 12 files changed, 1285 insertions(+), 517 deletions(-) create mode 100644 app/src/test/resources/quickpay/android-ledger.json create mode 100644 app/src/test/resources/quickpay/ios-ledger.json diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index 7332bf4cb..da37cc19a 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -17,7 +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.QuickPaySpendReservation +import to.bitkit.repositories.QuickPayLedger import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton @@ -165,9 +165,7 @@ data class AppCacheData( val backgroundReceive: NewTransactionSheetDetails? = null, val addressSearchLastUsedReceiveIndexes: Map = mapOf(), val addressSearchLastUsedChangeIndexes: Map = mapOf(), - val quickPaySpendDayKey: String = "", - val quickPaySpentCentsToday: Long = 0L, - val quickPayReservations: Map = emptyMap(), + 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/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index c70c1c467..74be70c81 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1257,13 +1257,24 @@ class LightningRepo @Inject constructor( suspend fun payInvoice( bolt11: String, sats: ULong? = null, + ): Result = payInvoice(bolt11, sats, onBeforeSend = {}) + + suspend fun payInvoice( + bolt11: String, + sats: ULong? = null, + onBeforeSend: suspend () -> Unit, ): Result = executeWhenNodeRunning("payInvoice") { waitForUsableChannels() + onBeforeSend() 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()) { diff --git a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt index 583722622..b39e04d91 100644 --- a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt @@ -1,33 +1,102 @@ package to.bitkit.repositories +import kotlinx.coroutines.CancellationException +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.Bolt11Invoice +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 @Singleton +@Suppress("LongParameterList", "LargeClass") class QuickPayRepo @Inject constructor( private val cacheStore: CacheStore, private val settingsStore: SettingsStore, private val currencyRepo: CurrencyRepo, + private val lightningRepo: LightningRepo, + private val pendingPaymentRepo: PendingPaymentRepo, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val clock: Clock, ) { companion object { private const val TAG = "QuickPayRepo" + const val LEDGER_VERSION = 1 + } + + 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) { @@ -45,115 +114,738 @@ class QuickPayRepo @Inject constructor( ?: return@runSuspendCatching false val reserveCents = quickPayReserveCents(converted.toUsdCents(), settings.quickPayAmount, amountSats) val capCents = quickPayCapCents(settings.quickPayAmount, settings.quickPayDailyLimitMultiplier) - val spentCentsToday = cacheStore.data.first().spendFor(currentDayKey()).spentCents - if (spentCentsToday + reserveCents <= capCents) return@runSuspendCatching true + val spend = mutex.withLock { currentSpend() } + if (!spend.supported) return@runSuspendCatching false + if (spend.spentCents + reserveCents <= capCents) return@runSuspendCatching true Logger.info( - "Skipping QuickPay: daily spend '$spentCentsToday' + '$reserveCents' exceeds cap '$capCents'", + "Skipping QuickPay: daily spend '${spend.spentCents}' + '$reserveCents' exceeds cap '$capCents'", context = TAG, ) false } } - suspend fun tryReserve(amountSats: ULong): Result = withContext(ioDispatcher) { + suspend fun reserveBound( + paymentHash: String, + amountSats: ULong, + ): Result = withContext(ioDispatcher) { runSuspendCatching { + if (paymentHash.isBlank()) return@runSuspendCatching null val settings = settingsStore.data.first() + val thresholdSats = currencyRepo.convertFiatToSats( + settings.quickPayAmount.toDouble(), + USD, + ).getOrNull() + if (thresholdSats == null || thresholdSats == 0uL || amountSats > thresholdSats) { + return@runSuspendCatching 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) - val dayKey = currentDayKey() - var reserved: QuickPaySpendReservation? = null - cacheStore.update { - val spend = it.spendFor(dayKey) - if (spend.spentCents + amountCents > capCents) return@update it - reserved = QuickPaySpendReservation(amountCents = amountCents, dayKey = spend.dayKey) - it.copy( - quickPaySpendDayKey = spend.dayKey, - quickPaySpentCentsToday = spend.spentCents + amountCents, + mutex.withLock { + 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 + var next = ledger.pruned(spend.dayKey) + 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, + ) + } + if (!wrote) null else reserved + } + } + } + + suspend fun noteTerminal( + paymentId: String?, + paymentHash: String?, + success: Boolean, + feePaidMsat: ULong? = null, + failureReason: PaymentFailureReason? = null, + ): QuickPayTerminalOutcome = withContext(ioDispatcher) { + mutex.withLock { + noteTerminalLocked( + paymentId = paymentId, + paymentHash = paymentHash, + success = success, + feePaidMsat = feePaidMsat, + failureReason = failureReason, + ) + } + } + + suspend fun reconcileAgainstLdk() { + val rows = lightningRepo.listPaymentsOrNull()?.map { QuickPayReconcileRow(it) } + mutex.withLock { + val live = opsByKey.values + .filter { !it.dispatched } + .map { it.invoiceHash } + .toSet() + reconcileLocked(rows, live) + } + } + + @Suppress("LongMethod", "CyclomaticComplexMethod", "ReturnCount", "ThrowsCount") + private suspend fun payNow(session: QuickPaySession, request: QuickPayPayRequest) { + val invoice = resolveInvoice(session, request) ?: return + val invoiceHash = parseInvoiceHash(invoice.bolt11) + if (invoiceHash == null) { + emitToSession( + session.id, + QuickPaySessionEvent.Error( + invoice.parseError ?: QuickPayConversionError(), + invoice.bolt11, + ), + ) + return + } + + val recovered = mutex.withLock { + val existing = opsByKey[invoiceHash] + if (existing != null) { + existing.sessionId = session.id + true + } else { + val open = currentLedger()?.recordMatching(invoiceHash) + if (open != null) { + registerOp( + InFlightOp( + invoiceHash = invoiceHash, + displaySats = invoice.amountSats, + paymentRequest = invoice.bolt11, + dispatched = true, + sessionId = session.id, + job = null, + paymentId = open.paymentId, + ), + ) + true + } else { + false + } + } + } + if (recovered) { + reconcileAgainstLdk() + return + } + + val reserved = reserveBound(invoiceHash, invoice.amountSats).getOrElse { + emitToSession(session.id, QuickPaySessionEvent.Error(it, invoice.bolt11)) + return + } + if (reserved == null) { + Logger.info("Skipping QuickPay pay: daily spend reserve failed for '${invoice.amountSats}'", context = TAG) + emitToSession(session.id, QuickPaySessionEvent.FallBackToConfirm) + return + } + + val op = InFlightOp( + invoiceHash = invoiceHash, + displaySats = invoice.amountSats, + paymentRequest = invoice.bolt11, + dispatched = false, + sessionId = session.id, + job = null, + paymentId = null, + ) + val cancelledBeforeDispatch = mutex.withLock { + if (sessionFlows[session.id] == null) { + writeLedger { ledger, _ -> releaseRecord(ledger, invoiceHash) } + true + } else { + op.job = coroutineContext[Job] + registerOp(op) + false + } + } + if (cancelledBeforeDispatch) return + + try { + val paid = lightningRepo.payInvoice(bolt11 = invoice.bolt11, sats = null) { + mutex.withLock { + val current = opsByKey[invoiceHash] + if (current == null || current.cancelBeforeDispatch) { + throw CancellationException("QuickPay cancelled before send") + } + current.dispatched = true + } + } + paid.onSuccess { paymentId -> + markSubmittedLocked(invoiceHash, paymentId) + mutex.withLock { + val current = opsByKey[invoiceHash] ?: return@withLock + current.paymentId = paymentId + if (paymentId.isNotBlank() && paymentId != invoiceHash) { + opsByKey[paymentId] = current + } + } + }.onFailure { error -> + if (error is CancellationException) throw error + handleDispatchError(invoiceHash, invoice.bolt11, error) + } + + val current = mutex.withLock { opsByKey[invoiceHash] } ?: return + withTimeoutOrNull(LightningRepo.SEND_LN_TIMEOUT) { + current.settled.await() + } + mutex.withLock { + val live = opsByKey[invoiceHash] ?: return@withLock + if (live.settled.isCompleted || live.emitted) return@withLock + val attachedId = live.sessionId + if (attachedId != null) { + live.emitted = true + pendingPaymentRepo.track(invoiceHash) + emitToSession( + attachedId, + QuickPaySessionEvent.Pending( + paymentHash = invoiceHash, + amount = invoice.amountSats.toLong(), + paymentRequest = invoice.bolt11, + ), + ) + } + } + } catch (e: CancellationException) { + mutex.withLock { + val current = opsByKey[invoiceHash] + if (current == null || current.dispatched) return@withLock + writeLedger { ledger, _ -> releaseRecord(ledger, invoiceHash) } + removeOpLocked(current) + } + throw e + } + } + + 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 + }, ) } - reserved } } - suspend fun remember( - paymentHash: String, - reservation: QuickPaySpendReservation, - ): Result = withContext(ioDispatcher) { - runSuspendCatching { - if (paymentHash.isBlank()) return@runSuspendCatching - cacheStore.update { - it.copy( - quickPayReservations = it.quickPayReservations + (paymentHash to reservation), + private fun parseInvoiceHash(bolt11: String): String? { + return runCatching { Bolt11Invoice.fromStr(bolt11).paymentHash() }.getOrNull() + } + + private suspend fun handleDispatchError( + invoiceHash: String, + paymentRequest: String, + error: Throwable, + ) { + when (classifyDispatchError(error)) { + QuickPayDispatchClass.PRE_DISPATCH_REJECTION, + QuickPayDispatchClass.DUPLICATE_PAYMENT, + -> { + mutex.withLock { + val outcome = noteTerminalLocked( + paymentId = null, + paymentHash = invoiceHash, + success = false, + ) + emitOutcome(outcome, invoiceHash, error, paymentRequest) + } + } + QuickPayDispatchClass.AMBIGUOUS -> { + val rows = lightningRepo.listPaymentsOrNull()?.map { QuickPayReconcileRow(it) } + mutex.withLock { + val record = currentLedger()?.recordMatching(invoiceHash) + if (record != null && rows != null) { + applyAmbiguousLookupLocked(record, rows) + } + val remaining = currentLedger()?.recordMatching(invoiceHash) + val op = opsByKey[invoiceHash] + if (remaining == null) { + op?.let { removeOpLocked(it) } + } else { + op?.dispatched = true + } + emitToSession( + op?.sessionId, + QuickPaySessionEvent.Error(error, paymentRequest), + ) + op?.emitted = true + } + } + } + } + + 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() + writeLedger { ledger, _ -> releaseRecord(ledger, op.invoiceHash) } + removeOpLocked(op) + } + } + + private suspend fun markSubmittedLocked(invoiceHash: String, paymentId: String) { + mutex.withLock { + 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 reservation(paymentHash: String): Result = withContext(ioDispatcher) { - runSuspendCatching { - if (paymentHash.isBlank()) return@runSuspendCatching null - cacheStore.data.first().quickPayReservations[paymentHash] + @Suppress("CyclomaticComplexMethod", "ReturnCount") + private suspend fun noteTerminalLocked( + paymentId: String?, + paymentHash: String?, + success: Boolean, + feePaidMsat: ULong? = null, + failureReason: PaymentFailureReason? = null, + ): QuickPayTerminalOutcome { + val keys = listOfNotNull(paymentId, paymentHash).filter { it.isNotBlank() } + if (keys.isEmpty()) return QuickPayTerminalOutcome.None + + val snapshot = currentSpend() + if (!snapshot.supported) return QuickPayTerminalOutcome.None + val ledger = snapshot.ledger ?: return QuickPayTerminalOutcome.None + val index = keys.firstNotNullOfOrNull { ledger.recordIndex(it) } ?: return QuickPayTerminalOutcome.None + val record = ledger.records[index] + val op = opsByKey[record.invoicePaymentHash] ?: record.paymentId?.let { opsByKey[it] } + if (!success && !isAttributedFailure(record, op, paymentId, paymentHash)) { + return QuickPayTerminalOutcome.None } + + 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) + } + + val kind = if (success) { + QuickPayTerminalKind.SETTLED_SUCCESS + } else { + QuickPayTerminalKind.SETTLED_FAILURE + } + val outcome = QuickPayTerminalOutcome( + kind = kind, + invoicePaymentHash = record.invoicePaymentHash, + ) + if (op != null && !op.emitted) { + op.emitted = true + val event = if (success) { + val feeSats = msatFloorOf(feePaidMsat ?: 0u) + QuickPaySessionEvent.Success( + paymentHash = record.invoicePaymentHash, + amountWithFee = (op.displaySats.safe() + feeSats.safe()).toLong(), + ) + } else { + QuickPaySessionEvent.Error( + QuickPayPaymentFailedError( + paymentHash = record.invoicePaymentHash, + reason = failureReason, + paymentRequest = op.paymentRequest, + ), + op.paymentRequest, + ) + } + emitToSession(op.sessionId, event) + op.settled.complete(Unit) + removeOpLocked(op) + } else { + op?.settled?.complete(Unit) + op?.let { removeOpLocked(it) } + } + return outcome } - suspend fun release(paymentHash: String): Result = withContext(ioDispatcher) { - runSuspendCatching { - if (paymentHash.isBlank()) return@runSuspendCatching - cacheStore.update { data -> - val reservation = data.quickPayReservations[paymentHash] ?: return@update data - val remaining = data.quickPayReservations - paymentHash - val spend = data.spendFor(reservation.dayKey) - if (reservation.dayKey != spend.dayKey) { - return@update data.copy(quickPayReservations = remaining) + 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, + ) { + val match = pickMatch(record, rows) ?: return + when (match.status) { + QuickPayReconcileRow.Status.PENDING -> Unit + QuickPayReconcileRow.Status.SUCCEEDED -> { + writeLedger { ledger, _ -> + val index = ledger.recordIndex(record.invoicePaymentHash) ?: return@writeLedger ledger + ledger.copy(records = ledger.records.toMutableList().also { it.removeAt(index) }) } - data.copy( - quickPaySpentCentsToday = (spend.spentCents - reservation.amountCents).coerceAtLeast(0L), - quickPayReservations = remaining, + } + QuickPayReconcileRow.Status.FAILED -> { + val attributed = isAttributedFailure( + record, + opsByKey[record.invoicePaymentHash], + match.paymentId, + match.invoicePaymentHash, ) + if (!attributed) { + return + } + writeLedger { ledger, _ -> releaseRecord(ledger, record.invoicePaymentHash) } } } } - suspend fun releaseUnbound(reservation: QuickPaySpendReservation): Result = withContext(ioDispatcher) { - runSuspendCatching { - cacheStore.update { - if (reservation.dayKey != it.quickPaySpendDayKey) return@update it - it.copy( - quickPaySpentCentsToday = (it.quickPaySpentCentsToday - reservation.amountCents).coerceAtLeast(0L), + @Suppress("LoopWithTooManyJumpStatements") + private suspend fun reconcileLocked( + rows: List?, + liveSubmittingHashes: Set, + ) { + if (rows == null) return + writeLedger { ledger, dayKey -> + var next = ledger.pruned(dayKey) + val remaining = mutableListOf() + var spent = next.spentCents + for (record in next.records) { + if (liveSubmittingHashes.contains(record.invoicePaymentHash)) { + remaining.add(record) + continue + } + val match = pickMatch(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 -> { + val op = opsByKey[record.invoicePaymentHash] + if (!isAttributedFailure(record, op, match.paymentId, match.invoicePaymentHash)) { + remaining.add(record) + } else if (record.dayKey == next.dayKey) { + spent = (spent - record.amountCents).coerceAtLeast(0L) + } + } + } + } + next.copy(records = remaining, spentCents = spent) + } + } + + private fun pickMatch( + 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 } } } - suspend fun clear(paymentHash: String): Result = withContext(ioDispatcher) { - runSuspendCatching { - if (paymentHash.isBlank()) return@runSuspendCatching - cacheStore.update { - if (paymentHash !in it.quickPayReservations) return@update it - it.copy(quickPayReservations = it.quickPayReservations - paymentHash) + private suspend fun currentSpend(): SpendSnapshot { + val data = cacheStore.data.first() + val (ledger, supported) = data.resolvedLedger() + val dayKey = currentDayKey() + if (!supported) return SpendSnapshot(dayKey, 0L, supported = false, ledger = ledger) + val spend = spendFor(ledger, dayKey) + return SpendSnapshot(spend.dayKey, spend.spentCents, supported = true, ledger = ledger) + } + + private suspend fun currentLedger(): QuickPayLedger? { + val (ledger, supported) = cacheStore.data.first().resolvedLedger() + return ledger.takeIf { supported } + } + + 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 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( + outcome: QuickPayTerminalOutcome, + invoiceHash: String, + error: Throwable, + paymentRequest: String, + ) { + val op = opsByKey[invoiceHash] + if (op != null && !op.emitted) { + op.emitted = true + emitToSession(op.sessionId, QuickPaySessionEvent.Error(error, paymentRequest)) + op.settled.complete(Unit) + } + op?.let { removeOpLocked(it) } + if (outcome == QuickPayTerminalOutcome.None) { + emitToSession(op?.sessionId, QuickPaySessionEvent.Error(error, paymentRequest)) + } + } + + private fun emitToSession(sessionId: String?, event: QuickPaySessionEvent) { + if (sessionId == null) return + sessionFlows[sessionId]?.tryEmit(event) } private fun currentDayKey(): String = clock.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.toString() + + 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 SpendSnapshot( + val dayKey: String, + val spentCents: Long, + val supported: Boolean, + val ledger: QuickPayLedger?, + ) +} + +internal fun classifyDispatchError(error: Throwable): QuickPayDispatchClass { + if (PrivatePaykitErrorClassifier.isDuplicatePaymentError(error)) { + return QuickPayDispatchClass.DUPLICATE_PAYMENT + } + 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 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 QuickPayTerminalKind { + NONE, + SETTLED_SUCCESS, + SETTLED_FAILURE, +} + +data class QuickPayTerminalOutcome( + val kind: QuickPayTerminalKind = QuickPayTerminalKind.NONE, + val invoicePaymentHash: String? = null, +) { + val wasQuickPay: Boolean get() = kind != QuickPayTerminalKind.NONE + + companion object { + val None = QuickPayTerminalOutcome() + } } @Serializable -data class QuickPaySpendReservation( +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, ) -private data class QuickPayDaySpend( +@Serializable +data class QuickPayLedger( + val version: Int, val dayKey: String, val spentCents: Long, + val records: List = emptyList(), ) +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 + }, + ) +} + +class QuickPayConversionError : AppError("Currency conversion failed") + +class QuickPayPaymentFailedError( + val paymentHash: String, + val reason: PaymentFailureReason?, + val paymentRequest: String?, +) : AppError(reason?.name) + private fun quickPayCapCents(thresholdUsd: Int, multiplier: Int): Long = thresholdUsd.toLong() * 100L * multiplier.toLong() @@ -167,10 +859,50 @@ private fun quickPayReserveCents( return maxOf(clamped, 1L) } -class QuickPayConversionError : AppError("Currency conversion failed") +private data class QuickPayDaySpend( + val dayKey: String, + val spentCents: Long, +) -private fun AppCacheData.spendFor(dayKey: String): QuickPayDaySpend = when { - quickPaySpendDayKey.isEmpty() || dayKey > quickPaySpendDayKey -> QuickPayDaySpend(dayKey, 0L) - dayKey == quickPaySpendDayKey -> QuickPayDaySpend(dayKey, quickPaySpentCentsToday) - else -> QuickPayDaySpend(quickPaySpendDayKey, quickPaySpentCentsToday) +private fun AppCacheData.resolvedLedger(): Pair { + val ledger = quickPayLedger + if (ledger != null) { + return ledger to (ledger.version == QuickPayRepo.LEDGER_VERSION) + } + return QuickPayLedger( + version = QuickPayRepo.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): QuickPayLedger { + if (currentDay.isEmpty()) return this + return copy(records = records.filter { it.dayKey >= currentDay }) +} + +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) } 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 75ae2ea49..aad987ca4 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 @@ -45,10 +48,16 @@ fun SendQuickPayScreen( ) { 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) } } diff --git a/app/src/main/java/to/bitkit/utils/Errors.kt b/app/src/main/java/to/bitkit/utils/Errors.kt index a3c915c77..6cc3b0f97 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 d602576fe..18c397820 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1161,7 +1161,12 @@ class AppViewModel @Inject constructor( private suspend fun handlePaymentFailed(event: Event.PaymentFailed) { event.paymentHash?.let { paymentHash -> activityRepo.handlePaymentEvent(paymentHash) - quickPayRepo.release(paymentHash) + quickPayRepo.noteTerminal( + paymentId = event.paymentId, + paymentHash = paymentHash, + success = false, + failureReason = event.reason, + ) if (pendingPaymentRepo.isPending(paymentHash)) { clearPendingContactPaymentContext(paymentHash) pendingPaymentRepo.resolve(PendingPaymentResolution.Failure(paymentHash, event.reason)) @@ -1242,40 +1247,48 @@ class AppViewModel @Inject constructor( } private suspend fun handlePaymentSuccessful(event: Event.PaymentSuccessful) { - event.paymentHash.let { paymentHash -> - activityRepo.handlePaymentEvent(paymentHash) - val isQuickPay = quickPayRepo.reservation(paymentHash).getOrNull() != null - quickPayRepo.clear(paymentHash) - if (pendingPaymentRepo.isPending(paymentHash)) { - syncContactForActivity(paymentHash) - val amountWithFeeSats = if (isQuickPay) { - val principal = ( - activityRepo.findActivityByPaymentId( - paymentHashOrTxId = paymentHash, - type = ActivityFilter.LIGHTNING, - txType = PaymentType.SENT, - retry = true, - ).getOrNull() as? Activity.Lightning - )?.v1?.value - principal?.let { - (it.safe() + msatFloorOf(event.feePaidMsat ?: 0u).safe()).toLong() - } - } else { - null - } - pendingPaymentRepo.resolve( - PendingPaymentResolution.Success( - paymentHash = paymentHash, - amountWithFeeSats = amountWithFeeSats, - ), - ) - if (shouldNotifyPendingResolution(paymentHash)) { - notifyPendingPaymentSucceeded() - } - return - } + val paymentHash = event.paymentHash + activityRepo.handlePaymentEvent(paymentHash) + val isQuickPay = quickPayRepo.noteTerminal( + 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 @@ -3481,7 +3494,10 @@ class AppViewModel @Inject constructor( fun hideSheet() = hideSheet(shouldFlushDeferredScan = true) private fun hideSheet(shouldFlushDeferredScan: Boolean) { - if (_currentSheet.value is Sheet.Send) resetQuickPay() + 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 ebfd7a53b..c15d88e13 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -6,229 +6,95 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope 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.R -import to.bitkit.ext.WatchResult -import to.bitkit.ext.callbackAmountMsats -import to.bitkit.ext.supportPaymentRequest import to.bitkit.ext.toCompactFailureType import to.bitkit.ext.toSendFailureDetails -import to.bitkit.ext.watchUntil import to.bitkit.models.SendFailureDetails -import to.bitkit.models.msatFloorOf -import to.bitkit.models.safe import to.bitkit.repositories.LightningRepo -import to.bitkit.repositories.PaymentPendingException -import to.bitkit.repositories.PendingPaymentRepo import to.bitkit.repositories.QuickPayConversionError +import to.bitkit.repositories.QuickPayPayRequest +import to.bitkit.repositories.QuickPayPaymentFailedError import to.bitkit.repositories.QuickPayRepo -import to.bitkit.repositories.QuickPaySpendReservation -import to.bitkit.utils.AppError -import to.bitkit.utils.Logger +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 - private var payJob: Job? = null - - fun pay(data: QuickPayData) { - if (payJob?.isActive == true || _uiState.value.result != null) return - payJob = viewModelScope.launch { payNow(data) } - } - - internal suspend fun payNow(data: QuickPayData) { - val invoice = resolveQuickPayInvoice(data) ?: return - val reservation = reserveSpend(invoice.displaySats) ?: return - - sendLightning(invoice, reservation) - .onSuccess { onPaymentSuccess(it.paymentHash, invoice.displaySats, it.feePaidSats) } - .onFailure { onPaymentFailure(it, invoice, reservation) } - } - - private suspend fun reserveSpend(amountSats: ULong): QuickPaySpendReservation? { - val reserved = quickPayRepo.tryReserve(amountSats).getOrElse { - setError(it) - return null - } - if (reserved == null) { - Logger.info("Skipping QuickPay pay: daily spend reserve failed for '$amountSats'", context = TAG) - _uiState.update { it.copy(result = QuickPayResult.FallBackToConfirm) } - return null + 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()) } + } } - return reserved } - private suspend fun onPaymentSuccess(paymentHash: String, displaySats: ULong, feePaidSats: ULong) { - Logger.info("QuickPay lightning payment successful", context = TAG) - quickPayRepo.clear(paymentHash) - _uiState.update { - it.copy( - result = QuickPayResult.Success( - paymentHash = paymentHash, - amountWithFee = (displaySats.safe() + feePaidSats.safe()).toLong(), - ) - ) + fun detach(session: QuickPaySession) { + quickPayRepo.detach(session) + if (this.session?.id == session.id) { + this.session = null } } - private suspend fun onPaymentFailure( - error: Throwable, - invoice: QuickPayInvoice, - reservation: QuickPaySpendReservation, - ) { - if (error is PaymentPendingException) { - Logger.info("QuickPay lightning payment pending", context = TAG) - _uiState.update { - it.copy( - result = QuickPayResult.Pending( - paymentHash = error.paymentHash, - amount = invoice.displaySats.toLong(), - paymentRequest = invoice.paymentRequest, - ) - ) - } - return - } - Logger.error("QuickPay lightning payment failed", error, context = TAG) - if (error is QuickPayPaymentFailedError) { - quickPayRepo.release(error.paymentHash) - } else { - quickPayRepo.releaseUnbound(reservation) - } - handleQuickPayFailure(error, invoice) + fun pay(session: QuickPaySession, data: QuickPayData) { + if (_uiState.value.result != null) return + quickPayRepo.pay(session, data.toPayRequest()) } - private fun setError(error: Throwable) { - val localizedMessage = when (error) { - is QuickPayConversionError -> { - context.getString(R.string.wallet__send_quickpay__currency_conversion) - } - else -> null - } - val failure = if (localizedMessage != null) { - SendFailureDetails( - message = localizedMessage, - failureType = error.toCompactFailureType(), - resetRoutingCachesOnRetry = false, - ) - } else { - error.toSendFailureDetails(context) - } - _uiState.update { it.copy(result = QuickPayResult.Error(failure)) } + override fun onCleared() { + session?.let { quickPayRepo.detach(it) } + super.onCleared() } - 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 - }, - ) - } - } + 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)) } - 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)) + 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) } } - private suspend fun sendLightning( - invoice: QuickPayInvoice, - reservation: QuickPaySpendReservation, - ): Result { - val hash = lightningRepo.payInvoice(bolt11 = invoice.bolt11, sats = invoice.amount) - .onFailure { exception -> - return Result.failure(exception) - } - .getOrDefault("") - - return coroutineScope { - val settled = async { - lightningRepo.nodeEvents.watchUntil(LightningRepo.SEND_LN_TIMEOUT) { - when (it) { - is Event.PaymentSuccessful if it.paymentHash == hash -> WatchResult.Complete( - Result.success( - SettledQuickPayPayment( - paymentHash = hash, - feePaidSats = msatFloorOf(it.feePaidMsat ?: 0u), - ) - ) - ) - - is Event.PaymentFailed if it.paymentHash == hash -> WatchResult.Complete( - Result.failure( - QuickPayPaymentFailedError( - paymentHash = hash, - reason = it.reason, - paymentRequest = invoice.bolt11, - ) - ) - ) - - else -> WatchResult.Continue() - } - } - } - quickPayRepo.remember(paymentHash = hash, reservation = reservation) - pendingPaymentRepo.track(hash) - val result = settled.await() - if (result != null) return@coroutineScope 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) } } -private data class SettledQuickPayPayment( - val paymentHash: PaymentId, - val feePaidSats: ULong, -) - sealed class QuickPayResult { data class Success( val paymentHash: String, @@ -249,18 +115,3 @@ sealed class 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 paymentHash: String, - val reason: PaymentFailureReason?, - val paymentRequest: String?, -) : AppError(reason?.name) diff --git a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt index 6be381086..1ccf9c8c6 100644 --- a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt @@ -10,18 +10,24 @@ 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.mock +import org.mockito.kotlin.never +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 @@ -39,10 +45,13 @@ class QuickPayRepoTest : BaseUnitTest() { 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 lateinit var sut: QuickPayRepo @@ -50,6 +59,8 @@ class QuickPayRepoTest : BaseUnitTest() { fun setUp() = runBlocking { cacheStore.reset() 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) @@ -68,6 +79,8 @@ class QuickPayRepoTest : BaseUnitTest() { cacheStore = cacheStore, settingsStore = settingsStore, currencyRepo = currencyRepo, + lightningRepo = lightningRepo, + pendingPaymentRepo = pendingPaymentRepo, ioDispatcher = testDispatcher, clock = clock, ) @@ -77,89 +90,109 @@ class QuickPayRepoTest : BaseUnitTest() { fun tearDown() = runBlocking { cacheStore.reset() } @Test - fun `tryReserve on clock rollback keeps existing spend`() = test { - assertNotNull(sut.tryReserve(500u).getOrThrow()) + 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.tryReserve(200u).getOrThrow()) - assertEquals(350L, cacheStore.data.first().quickPaySpentCentsToday) + assertNotNull(sut.reserveBound("b", 200u).getOrThrow()) + assertEquals(350L, spentCents()) clock.instant = Instant.parse("2026-08-15T12:00:00Z") - assertEquals(350L, cacheStore.data.first().quickPaySpentCentsToday) + assertEquals(350L, spentCents()) } @Test - fun `tryReserve accumulates on the same day and resets on a new day`() = test { - assertNotNull(sut.tryReserve(400u).getOrThrow()) - assertNotNull(sut.tryReserve(300u).getOrThrow()) - assertEquals(350L, cacheStore.data.first().quickPaySpentCentsToday) + 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.tryReserve(800u).getOrThrow()) - assertEquals(400L, cacheStore.data.first().quickPaySpentCentsToday) + assertNotNull(sut.reserveBound("c", 800u).getOrThrow()) + assertEquals(400L, spentCents()) } @Test - fun `tryReserve reserves under the cap and rejects over it`() = test { + fun `reserveBound reserves under the cap and rejects over it`() = test { settingsData.value = settingsData.value.copy(quickPayDailyLimitMultiplier = 2) - assertNotNull(sut.tryReserve(1000u).getOrThrow()) - assertNotNull(sut.tryReserve(1000u).getOrThrow()) - assertNull(sut.tryReserve(1000u).getOrThrow()) - assertEquals(1000L, cacheStore.data.first().quickPaySpentCentsToday) + assertNotNull(sut.reserveBound("a", 1000u).getOrThrow()) + assertNotNull(sut.reserveBound("b", 1000u).getOrThrow()) + assertNull(sut.reserveBound("c", 1000u).getOrThrow()) + assertEquals(1000L, spentCents()) } @Test - fun `releaseUnbound rolls back a reservation`() = test { - val reserved = requireNotNull(sut.tryReserve(1000u).getOrThrow()) + fun `reserveBound rejects a duplicate invoice hash`() = test { + assertNotNull(sut.reserveBound("abc", 1000u).getOrThrow()) + assertNull(sut.reserveBound("abc", 1000u).getOrThrow()) + } + + @Test + fun `noteTerminal failure rolls back a reservation`() = test { + assertNotNull(sut.reserveBound("abc", 1000u).getOrThrow()) + markSubmitted("abc", "pid") - sut.releaseUnbound(reserved).getOrThrow() + val outcome = sut.noteTerminal(paymentId = "pid", paymentHash = "abc", success = false) - assertEquals(0L, cacheStore.data.first().quickPaySpentCentsToday) + assertEquals(QuickPayTerminalKind.SETTLED_FAILURE, outcome.kind) + assertEquals(0L, spentCents()) + assertTrue(cacheStore.data.first().quickPayLedger!!.records.isEmpty()) } @Test - fun `releaseUnbound on a prior day does not decrement the new day`() = test { - val old = requireNotNull(sut.tryReserve(1000u).getOrThrow()) + fun `noteTerminal 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.tryReserve(800u).getOrThrow()) + assertNotNull(sut.reserveBound("new", 800u).getOrThrow()) - sut.releaseUnbound(old).getOrThrow() + sut.noteTerminal(paymentId = "old-pid", paymentHash = "old", success = false) - assertEquals(400L, cacheStore.data.first().quickPaySpentCentsToday) + assertEquals(400L, spentCents()) } @Test - fun `release frees pending spend by payment hash`() = test { - val reserved = requireNotNull(sut.tryReserve(1000u).getOrThrow()) - sut.remember("abc", reserved).getOrThrow() + fun `noteTerminal success keeps spend`() = test { + assertNotNull(sut.reserveBound("abc", 1000u).getOrThrow()) - sut.release("abc").getOrThrow() + val outcome = sut.noteTerminal(paymentId = "pid", paymentHash = "abc", success = true) - assertEquals(0L, cacheStore.data.first().quickPaySpentCentsToday) - assertNull(sut.reservation("abc").getOrThrow()) + assertEquals(QuickPayTerminalKind.SETTLED_SUCCESS, outcome.kind) + assertEquals(500L, spentCents()) + assertTrue(cacheStore.data.first().quickPayLedger!!.records.isEmpty()) } @Test - fun `clear keeps spend after success`() = test { - val reserved = requireNotNull(sut.tryReserve(1000u).getOrThrow()) - sut.remember("abc", reserved).getOrThrow() + fun `noteTerminal is idempotent`() = test { + assertNotNull(sut.reserveBound("abc", 1000u).getOrThrow()) + sut.noteTerminal(paymentId = null, paymentHash = "abc", success = true) - sut.clear("abc").getOrThrow() + val second = sut.noteTerminal(paymentId = null, paymentHash = "abc", success = true) - assertEquals(500L, cacheStore.data.first().quickPaySpentCentsToday) - assertNull(sut.reservation("abc").getOrThrow()) + assertEquals(QuickPayTerminalOutcome.None, second) + assertEquals(500L, spentCents()) } @Test - fun `release on a prior day does not decrement the new day`() = test { - val old = requireNotNull(sut.tryReserve(1000u).getOrThrow()) - sut.remember("old", old).getOrThrow() - clock.instant = Instant.parse("2026-08-16T12:00:00Z") - assertNotNull(sut.tryReserve(800u).getOrThrow()) + fun `dual aliases settle one record`() = test { + assertNotNull(sut.reserveBound("inv", 1000u).getOrThrow()) + sut.noteTerminal(paymentId = "pid", paymentHash = "other", success = true) + // paymentId was not stored yet; settle by invoice hash then alias + val first = sut.noteTerminal(paymentId = "pid", paymentHash = "inv", success = true) + val second = sut.noteTerminal(paymentId = "pid", paymentHash = "inv", success = false) + + assertEquals(QuickPayTerminalKind.SETTLED_SUCCESS, first.kind) + assertEquals(QuickPayTerminalOutcome.None, second) + } - sut.release("old").getOrThrow() + @Test + fun `unattributable failed event against submitting retains`() = test { + assertNotNull(sut.reserveBound("inv", 1000u).getOrThrow()) + + val outcome = sut.noteTerminal(paymentId = "stale-pid", paymentHash = "other", success = false) - assertEquals(400L, cacheStore.data.first().quickPaySpentCentsToday) - assertNull(sut.reservation("old").getOrThrow()) + assertEquals(QuickPayTerminalOutcome.None, outcome) + assertEquals(500L, spentCents()) + assertEquals(1, cacheStore.data.first().quickPayLedger!!.records.size) } @Test @@ -170,7 +203,7 @@ class QuickPayRepoTest : BaseUnitTest() { @Test fun `canApply is false when daily cap would be exceeded`() = test { settingsData.value = settingsData.value.copy(quickPayDailyLimitMultiplier = 1) - assertNotNull(sut.tryReserve(1000u).getOrThrow()) + assertNotNull(sut.reserveBound("a", 1000u).getOrThrow()) assertFalse(sut.canApply(1000u).getOrThrow()) } @@ -185,23 +218,158 @@ class QuickPayRepoTest : BaseUnitTest() { @Test fun `zero cent conversion at a full cap is rejected`() = test { stubZeroCentConversion(7L) - repeat(5) { assertNotNull(sut.tryReserve(1000u).getOrThrow()) } + repeat(5) { assertNotNull(sut.reserveBound("h$it", 1000u).getOrThrow()) } assertFalse(sut.canApply(7u).getOrThrow()) - assertNull(sut.tryReserve(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.tryReserve(7u).getOrThrow()) + val reserved = requireNotNull(sut.reserveBound("dust", 7u).getOrThrow()) assertEquals(1L, reserved.amountCents) - assertEquals(1L, cacheStore.data.first().quickPaySpentCentsToday) + 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 = QuickPayRepo( + cacheStore = cacheStore, + settingsStore = settingsStore, + currencyRepo = currencyRepo, + lightningRepo = lightningRepo, + pendingPaymentRepo = pendingPaymentRepo, + ioDispatcher = testDispatcher, + clock = clock, + ) + + assertNull(reloaded.reserveBound("inv", 1000u).getOrThrow()) + verify(lightningRepo, never()).payInvoice(any(), anyOrNull(), any()) + } + + @Test + fun `ios ledger fixture decodes`() { + val raw = javaClass.getResource("/quickpay/ios-ledger.json")!!.readText() + val ledger = json.decodeFromString(raw) + 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 raw = javaClass.getResource("/quickpay/android-ledger.json")!!.readText() + val ledger = json.decodeFromString(raw) + 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 `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()) + } + + 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 fun stubZeroCentConversion(dustSats: Long) { whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())).thenAnswer { invocation -> val sats = invocation.getArgument(0) @@ -217,17 +385,6 @@ class QuickPayRepoTest : BaseUnitTest() { ) } } - - @Test - fun `tryReserve fails with conversion error when rates are unavailable`() = test { - whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())).thenAnswer { - throw QuickPayConversionError() - } - - val result = sut.tryReserve(500u) - - assertTrue(result.exceptionOrNull() is QuickPayConversionError) - } } private class MutableClock(var instant: Instant) : Clock { diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 7d4adc835..8982ba739 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -98,7 +98,6 @@ import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitPaymentResult import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.repositories.QuickPayRepo -import to.bitkit.repositories.QuickPaySpendReservation import to.bitkit.repositories.SamRockRepo import to.bitkit.repositories.SettledReceiveAddress import to.bitkit.repositories.SettledReceiveInvoice @@ -230,9 +229,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { stubSettingsStore() whenever(cacheStore.data).thenReturn(flowOf(AppCacheData())) whenever { quickPayRepo.canApply(any()) }.thenReturn(Result.success(false)) - whenever { quickPayRepo.reservation(any()) }.thenReturn(Result.success(null)) - whenever { quickPayRepo.clear(any()) }.thenReturn(Result.success(Unit)) - whenever { quickPayRepo.release(any()) }.thenReturn(Result.success(Unit)) + whenever { + quickPayRepo.noteTerminal(anyOrNull(), anyOrNull(), any(), anyOrNull(), anyOrNull()) + }.thenReturn(to.bitkit.repositories.QuickPayTerminalOutcome.None) whenever { activityRepo.findActivityByPaymentId(any(), any(), any(), any()) } .thenReturn(Result.failure(Exception("activity not found"))) whenever(transferRepo.activeTransfers).thenReturn(flowOf(emptyList())) @@ -1728,7 +1727,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(pendingPaymentRepo).resolve(PendingPaymentResolution.Success(paymentHash)) verify(activityRepo).setContact(contactPublicKey = contactKey, forPaymentId = paymentHash) - verify(quickPayRepo).clear(paymentHash) + verify(quickPayRepo).noteTerminal( + paymentId = "payment_id", + paymentHash = paymentHash, + success = true, + feePaidMsat = 10uL, + failureReason = null, + ) } @Test @@ -1754,7 +1759,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { reason = PaymentFailureReason.RETRIES_EXHAUSTED, ) ) - verify(quickPayRepo).release(paymentHash) + verify(quickPayRepo).noteTerminal( + paymentId = "payment_id", + paymentHash = paymentHash, + success = false, + feePaidMsat = null, + failureReason = PaymentFailureReason.RETRIES_EXHAUSTED, + ) assertNull(pendingContactPaymentContext(paymentHash)) } @@ -1772,7 +1783,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) advanceUntilIdle() - verify(quickPayRepo).release(paymentHash) + verify(quickPayRepo).noteTerminal( + paymentId = "payment_id", + paymentHash = paymentHash, + success = false, + feePaidMsat = null, + failureReason = PaymentFailureReason.RETRIES_EXHAUSTED, + ) verify(pendingPaymentRepo, never()).resolve(any()) } @@ -1780,8 +1797,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `PaymentSuccessful clears disk reservation when not pending`() = test { val paymentHash = "restart_ok" whenever(pendingPaymentRepo.isPending(paymentHash)).thenReturn(false) - whenever { quickPayRepo.reservation(paymentHash) }.thenReturn( - Result.success(QuickPaySpendReservation(amountCents = 250L, dayKey = "2026-08-15")), + whenever { + quickPayRepo.noteTerminal(anyOrNull(), anyOrNull(), any(), anyOrNull(), anyOrNull()) + }.thenReturn( + to.bitkit.repositories.QuickPayTerminalOutcome( + kind = to.bitkit.repositories.QuickPayTerminalKind.SETTLED_SUCCESS, + invoicePaymentHash = paymentHash, + ), ) emitNodeEvent( @@ -1794,7 +1816,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) advanceUntilIdle() - verify(quickPayRepo).clear(paymentHash) + verify(quickPayRepo).noteTerminal( + paymentId = "payment_id", + paymentHash = paymentHash, + success = true, + feePaidMsat = 10uL, + failureReason = null, + ) verify(pendingPaymentRepo, never()).resolve(any()) } @@ -1803,7 +1831,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val paymentHash = "pending_confirm_hash" whenever(pendingPaymentRepo.isPending(paymentHash)).thenReturn(true) whenever(pendingPaymentRepo.isActive(paymentHash)).thenReturn(false) - whenever { quickPayRepo.reservation(paymentHash) }.thenReturn(Result.success(null)) + whenever { + quickPayRepo.noteTerminal(anyOrNull(), anyOrNull(), any(), anyOrNull(), anyOrNull()) + }.thenReturn(to.bitkit.repositories.QuickPayTerminalOutcome.None) advanceUntilIdle() emitNodeEvent( @@ -1830,8 +1860,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val activity = mock { on { v1 } doReturn activityV1 } whenever(pendingPaymentRepo.isPending(paymentHash)).thenReturn(true) whenever(pendingPaymentRepo.isActive(paymentHash)).thenReturn(false) - whenever { quickPayRepo.reservation(paymentHash) }.thenReturn( - Result.success(QuickPaySpendReservation(amountCents = 250L, dayKey = "2026-08-15")), + whenever { + quickPayRepo.noteTerminal(anyOrNull(), anyOrNull(), any(), anyOrNull(), anyOrNull()) + }.thenReturn( + to.bitkit.repositories.QuickPayTerminalOutcome( + kind = to.bitkit.repositories.QuickPayTerminalKind.SETTLED_SUCCESS, + invoicePaymentHash = paymentHash, + ), ) whenever { activityRepo.findActivityByPaymentId(any(), any(), any(), any()) } .thenReturn(Result.success(activity)) @@ -1853,7 +1888,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { amountWithFeeSats = 510L, ), ) - verify(quickPayRepo).clear(paymentHash) + verify(quickPayRepo).noteTerminal( + paymentId = "payment_id", + paymentHash = paymentHash, + success = true, + feePaidMsat = 10_000uL, + failureReason = null, + ) } @Test diff --git a/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt index aaac67fee..0940591d8 100644 --- a/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt @@ -1,23 +1,14 @@ package to.bitkit.viewmodels import android.content.Context -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runCurrent import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import org.lightningdevkit.ldknode.Event -import org.lightningdevkit.ldknode.PaymentFailureReason import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull -import org.mockito.kotlin.doSuspendableAnswer -import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.times @@ -29,14 +20,14 @@ import to.bitkit.R import to.bitkit.models.NodeLifecycleState import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState -import to.bitkit.repositories.PendingPaymentRepo import to.bitkit.repositories.QuickPayConversionError +import to.bitkit.repositories.QuickPayPayRequest import to.bitkit.repositories.QuickPayRepo -import to.bitkit.repositories.QuickPaySpendReservation +import to.bitkit.repositories.QuickPaySession +import to.bitkit.repositories.QuickPaySessionEvent import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals import kotlin.test.assertIs -import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) @@ -44,196 +35,113 @@ import kotlin.test.assertTrue class QuickPayViewModelTest : BaseUnitTest() { private val context: Context = mock() private val lightningRepo: LightningRepo = mock() - private val pendingPaymentRepo: PendingPaymentRepo = mock() private val quickPayRepo: QuickPayRepo = mock() - - private lateinit var nodeEvents: MutableSharedFlow - private val reserved = QuickPaySpendReservation(amountCents = 250L, dayKey = "2026-08-15") + private val events = MutableSharedFlow(extraBufferCapacity = 8) private lateinit var sut: QuickPayViewModel @Before fun setUp() { - nodeEvents = MutableSharedFlow(replay = 0, extraBufferCapacity = 0) 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(lightningRepo.nodeEvents).thenReturn(nodeEvents) - whenever { quickPayRepo.tryReserve(any()) }.thenReturn(Result.success(reserved)) - whenever { quickPayRepo.remember(any(), any()) }.thenReturn(Result.success(Unit)) - whenever { quickPayRepo.clear(any()) }.thenReturn(Result.success(Unit)) - whenever { quickPayRepo.release(any()) }.thenReturn(Result.success(Unit)) - whenever { quickPayRepo.releaseUnbound(any()) }.thenReturn(Result.success(Unit)) + whenever(quickPayRepo.attach(any())).thenReturn(events) sut = QuickPayViewModel( context = context, lightningRepo = lightningRepo, - pendingPaymentRepo = pendingPaymentRepo, quickPayRepo = quickPayRepo, ) } @Test - fun `happy path reserves before payInvoice and clears reservation on success`() = test { - whenever { lightningRepo.payInvoice(any(), anyOrNull()) }.thenReturn(Result.success("hash1")) - - launch { sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) } - nodeEvents.emit( - Event.PaymentSuccessful( - paymentId = "pid", - paymentHash = "hash1", - paymentPreimage = "preimage", - feePaidMsat = 1_000uL, - ), - ) + fun `success event maps to ui success`() = test { + val session = QuickPaySession() + sut.attach(session) + events.emit(QuickPaySessionEvent.Success(paymentHash = "hash1", amountWithFee = 501L)) advanceUntilIdle() - val order = inOrder(quickPayRepo, lightningRepo) - order.verify(quickPayRepo).tryReserve(500u) - order.verify(lightningRepo).payInvoice(bolt11 = "lnbcrt1test", sats = null) - order.verify(quickPayRepo).remember("hash1", reserved) - order.verify(quickPayRepo).clear("hash1") - verify(pendingPaymentRepo).track("hash1") val success = assertIs(sut.uiState.value.result) assertEquals("hash1", success.paymentHash) assertEquals(501L, success.amountWithFee) } @Test - fun `timeout remembers reservation before tracking pending`() = test { - whenever { lightningRepo.payInvoice(any(), anyOrNull()) }.thenReturn(Result.success("hash1")) - - sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) - advanceUntilIdle() - advanceTimeBy(LightningRepo.SEND_LN_TIMEOUT.inWholeMilliseconds + 1) - advanceUntilIdle() - - val order = inOrder(quickPayRepo, pendingPaymentRepo) - order.verify(quickPayRepo).remember("hash1", reserved) - order.verify(pendingPaymentRepo).track("hash1") - val pending = assertIs(sut.uiState.value.result) - assertEquals("hash1", pending.paymentHash) - } - - @Test - fun `immediate payInvoice failure releases spend without remembering`() = test { - whenever { lightningRepo.payInvoice(any(), anyOrNull()) } - .thenReturn(Result.failure(IllegalStateException("send failed"))) - - sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) - advanceUntilIdle() - - verify(quickPayRepo).releaseUnbound(reserved) - verify(quickPayRepo, never()).remember(any(), any()) - verify(pendingPaymentRepo, never()).track(any()) - assertIs(sut.uiState.value.result) - } - - @Test - fun `payment failed after submit releases hash keyed reservation`() = test { - whenever { lightningRepo.payInvoice(any(), anyOrNull()) }.thenReturn(Result.success("hash1")) - - launch { sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) } - nodeEvents.emit( - Event.PaymentFailed( - paymentId = "pid", + fun `pending event maps to ui pending`() = test { + val session = QuickPaySession() + sut.attach(session) + events.emit( + QuickPaySessionEvent.Pending( paymentHash = "hash1", - reason = PaymentFailureReason.ROUTE_NOT_FOUND, + amount = 500L, + paymentRequest = "lnbcrt1test", ), ) advanceUntilIdle() - verify(quickPayRepo).remember("hash1", reserved) - verify(quickPayRepo).release("hash1") - assertIs(sut.uiState.value.result) + val pending = assertIs(sut.uiState.value.result) + assertEquals("hash1", pending.paymentHash) } @Test - fun `reserve failure emits FallBackToConfirm`() = test { - whenever { quickPayRepo.tryReserve(any()) }.thenReturn(Result.success(null)) - - sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) - advanceUntilIdle() + fun `pay forwards to repo`() = test { + val session = QuickPaySession() + sut.attach(session) + val data = QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test") - assertEquals(QuickPayResult.FallBackToConfirm, sut.uiState.value.result) - verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) - } + sut.pay(session, data) - @Test - fun `fast fail during remember is collected and released`() = test { - val allowRemember = CompletableDeferred() - whenever { lightningRepo.payInvoice(any(), anyOrNull()) }.thenReturn(Result.success("hash1")) - whenever { quickPayRepo.remember(any(), any()) }.doSuspendableAnswer { - allowRemember.await() - Result.success(Unit) - } - - launch { sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) } - runCurrent() - assertTrue(nodeEvents.subscriptionCount.value > 0) - nodeEvents.emit( - Event.PaymentFailed( - paymentId = "pid", - paymentHash = "hash1", - reason = PaymentFailureReason.ROUTE_NOT_FOUND, - ), + verify(quickPayRepo).pay( + session, + QuickPayPayRequest.Bolt11(bolt11 = "lnbcrt1test", amountSats = 500u), ) - allowRemember.complete(Unit) - advanceUntilIdle() - - verify(quickPayRepo).release("hash1") - verify(pendingPaymentRepo).track("hash1") - assertIs(sut.uiState.value.result) + verify(quickPayRepo, never()).noteTerminal(any(), any(), any(), any(), any()) } @Test - fun `pay ignores re-entry while in flight`() = test { - val allowPay = CompletableDeferred() - whenever { lightningRepo.payInvoice(any(), anyOrNull()) }.doSuspendableAnswer { - allowPay.await() - Result.success("hash1") - } - val data = QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test") + fun `pay ignores re-entry after a result`() = test { + val session = QuickPaySession() + sut.attach(session) + events.emit(QuickPaySessionEvent.FallBackToConfirm) + advanceUntilIdle() - sut.pay(data) - sut.pay(data) - verify(quickPayRepo, times(1)).tryReserve(any()) - verify(lightningRepo, times(1)).payInvoice(any(), anyOrNull()) + sut.pay(session, QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) + sut.pay(session, QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) - allowPay.complete(Unit) - nodeEvents.emit( - Event.PaymentSuccessful( - paymentId = "pid", - paymentHash = "hash1", - paymentPreimage = "preimage", - feePaidMsat = 1_000uL, - ), - ) - advanceUntilIdle() + verify(quickPayRepo, never()).pay(any(), any()) } @Test fun `conversion failure uses currency conversion message`() = test { - whenever { quickPayRepo.tryReserve(any()) }.thenReturn(Result.failure(QuickPayConversionError())) - - sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) + 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) - verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) } @Test - fun `non conversion reserve failure is not the currency string`() = test { - whenever { quickPayRepo.tryReserve(any()) }.thenReturn(Result.failure(IllegalStateException("disk"))) + 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) + } - sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) + @Test + fun `viewmodel has no settlement methods on the repo besides noteTerminal from events`() = test { + val session = QuickPaySession() + sut.attach(session) + sut.pay(session, QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) advanceUntilIdle() - val error = assertIs(sut.uiState.value.result) - assertEquals("disk", error.failure.message) - verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) + verify(quickPayRepo, never()).noteTerminal(any(), any(), any(), any(), any()) } } diff --git a/app/src/test/resources/quickpay/android-ledger.json b/app/src/test/resources/quickpay/android-ledger.json new file mode 100644 index 000000000..2ebe67ac7 --- /dev/null +++ b/app/src/test/resources/quickpay/android-ledger.json @@ -0,0 +1,23 @@ +{ + "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" + } + ] +} diff --git a/app/src/test/resources/quickpay/ios-ledger.json b/app/src/test/resources/quickpay/ios-ledger.json new file mode 100644 index 000000000..33638a6bd --- /dev/null +++ b/app/src/test/resources/quickpay/ios-ledger.json @@ -0,0 +1,14 @@ +{ + "version": 1, + "dayKey": "2026-08-15", + "spentCents": 250, + "records": [ + { + "id": "rec-ios", + "amountCents": 250, + "dayKey": "2026-08-15", + "invoicePaymentHash": "inv-ios", + "phase": "submitting" + } + ] +} From ba9a2065366dd2947cd23395b7ffbddc5a9b1927 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 22 Aug 2026 23:00:48 +0200 Subject: [PATCH 35/71] refactor: split quickpay pay flow into stages --- .../to/bitkit/repositories/LightningRepo.kt | 7 +- .../to/bitkit/repositories/QuickPayRepo.kt | 549 +++++++++++------- .../bitkit/repositories/QuickPayRepoTest.kt | 176 +++++- 3 files changed, 518 insertions(+), 214 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 74be70c81..290ae5c43 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1257,15 +1257,15 @@ class LightningRepo @Inject constructor( suspend fun payInvoice( bolt11: String, sats: ULong? = null, - ): Result = payInvoice(bolt11, sats, onBeforeSend = {}) + ): Result = payInvoice(bolt11, sats, onBeforeSend = { true }) suspend fun payInvoice( bolt11: String, sats: ULong? = null, - onBeforeSend: suspend () -> Unit, + onBeforeSend: suspend () -> Boolean, ): Result = executeWhenNodeRunning("payInvoice") { waitForUsableChannels() - onBeforeSend() + if (!onBeforeSend()) return@executeWhenNodeRunning Result.failure(PaymentAbortedBeforeSend()) runCatching { lightningService.send(bolt11, sats) }.also { syncState() } @@ -2106,6 +2106,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/QuickPayRepo.kt b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt index b39e04d91..a1b8bd420 100644 --- a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt @@ -1,6 +1,5 @@ package to.bitkit.repositories -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Job @@ -60,12 +59,18 @@ class QuickPayRepo @Inject constructor( companion object { private const val TAG = "QuickPayRepo" const val LEDGER_VERSION = 1 + + private fun hashFromBolt11(bolt11: String): String? { + return runCatching { Bolt11Invoice.fromStr(bolt11).paymentHash() }.getOrNull() + } } private val scope = appScope(ioDispatcher, TAG) private val mutex = Mutex() private val opsByKey = mutableMapOf() private val sessionFlows = ConcurrentHashMap>() + internal var invoiceHashParser: (String) -> String? = Companion::hashFromBolt11 + internal var paymentRows: (() -> List?)? = null init { scope.launch { @@ -132,45 +137,8 @@ class QuickPayRepo @Inject constructor( ): Result = withContext(ioDispatcher) { runSuspendCatching { if (paymentHash.isBlank()) return@runSuspendCatching null - val settings = settingsStore.data.first() - val thresholdSats = currencyRepo.convertFiatToSats( - settings.quickPayAmount.toDouble(), - USD, - ).getOrNull() - if (thresholdSats == null || thresholdSats == 0uL || amountSats > thresholdSats) { - return@runSuspendCatching 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) - mutex.withLock { - 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 - var next = ledger.pruned(spend.dayKey) - 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, - ) - } - if (!wrote) null else reserved - } + val prepared = prepareReserve(amountSats) ?: return@runSuspendCatching null + mutex.withLock { writeReserveLocked(paymentHash, prepared) } } } @@ -193,7 +161,7 @@ class QuickPayRepo @Inject constructor( } suspend fun reconcileAgainstLdk() { - val rows = lightningRepo.listPaymentsOrNull()?.map { QuickPayReconcileRow(it) } + val rows = loadPaymentRows() mutex.withLock { val live = opsByKey.values .filter { !it.dispatched } @@ -203,135 +171,175 @@ class QuickPayRepo @Inject constructor( } } - @Suppress("LongMethod", "CyclomaticComplexMethod", "ReturnCount", "ThrowsCount") - private suspend fun payNow(session: QuickPaySession, request: QuickPayPayRequest) { + internal suspend fun payNow(session: QuickPaySession, request: QuickPayPayRequest) { val invoice = resolveInvoice(session, request) ?: return - val invoiceHash = parseInvoiceHash(invoice.bolt11) - if (invoiceHash == null) { - emitToSession( - session.id, - QuickPaySessionEvent.Error( - invoice.parseError ?: QuickPayConversionError(), - invoice.bolt11, - ), - ) - return + val invoiceHash = invoiceHashOrEmit(session, invoice) ?: return + when (preparePay(session, invoice, invoiceHash)) { + PreparePayResult.LIVE -> return + PreparePayResult.REJECTED -> return + PreparePayResult.RECOVERED -> settleRecovered(invoiceHash) + PreparePayResult.FRESH -> { + dispatchBolt11(invoice, invoiceHash) + awaitTerminalOrPending(invoiceHash) + } } + } - val recovered = mutex.withLock { + private fun invoiceHashOrEmit(session: QuickPaySession, invoice: ResolvedInvoice): String? { + val invoiceHash = parseInvoiceHash(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 - true - } else { - val open = currentLedger()?.recordMatching(invoiceHash) - if (open != null) { - registerOp( - InFlightOp( - invoiceHash = invoiceHash, - displaySats = invoice.amountSats, - paymentRequest = invoice.bolt11, - dispatched = true, - sessionId = session.id, - job = null, - paymentId = open.paymentId, - ), - ) - true - } else { - false - } + return@withLock PreparePayResult.LIVE + } + val open = currentLedger()?.recordMatching(invoiceHash) + if (open != null) { + registerOp(recoveredOp(session, invoice, invoiceHash, open)) + return@withLock PreparePayResult.RECOVERED + } + if (prepared == null || writeReserveLocked(invoiceHash, prepared) == null) { + rejectCap(session, invoice) + return@withLock PreparePayResult.REJECTED } - } - if (recovered) { - reconcileAgainstLdk() - return - } - - val reserved = reserveBound(invoiceHash, invoice.amountSats).getOrElse { - emitToSession(session.id, QuickPaySessionEvent.Error(it, invoice.bolt11)) - return - } - if (reserved == null) { - Logger.info("Skipping QuickPay pay: daily spend reserve failed for '${invoice.amountSats}'", context = TAG) - emitToSession(session.id, QuickPaySessionEvent.FallBackToConfirm) - return - } - - val op = InFlightOp( - invoiceHash = invoiceHash, - displaySats = invoice.amountSats, - paymentRequest = invoice.bolt11, - dispatched = false, - sessionId = session.id, - job = null, - paymentId = null, - ) - val cancelledBeforeDispatch = mutex.withLock { if (sessionFlows[session.id] == null) { writeLedger { ledger, _ -> releaseRecord(ledger, invoiceHash) } - true - } else { - op.job = coroutineContext[Job] - registerOp(op) - false + 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 } - if (cancelledBeforeDispatch) return + } - try { - val paid = lightningRepo.payInvoice(bolt11 = invoice.bolt11, sats = null) { - mutex.withLock { - val current = opsByKey[invoiceHash] - if (current == null || current.cancelBeforeDispatch) { - throw CancellationException("QuickPay cancelled before send") - } - current.dispatched = true - } - } - paid.onSuccess { paymentId -> - markSubmittedLocked(invoiceHash, paymentId) - mutex.withLock { - val current = opsByKey[invoiceHash] ?: return@withLock - current.paymentId = paymentId - if (paymentId.isNotBlank() && paymentId != invoiceHash) { - opsByKey[paymentId] = current - } - } - }.onFailure { error -> - if (error is CancellationException) throw error - handleDispatchError(invoiceHash, invoice.bolt11, error) + private suspend fun settleRecovered(invoiceHash: String) { + val rows = loadPaymentRows() + mutex.withLock { + val live = opsByKey.values + .filter { !it.dispatched } + .map { it.invoiceHash } + .toSet() + reconcileLocked(rows, live) + val op = opsByKey[invoiceHash] ?: return@withLock + if (currentLedger()?.recordMatching(invoiceHash) != null) { + emitPendingLocked(op) + return@withLock } - - val current = mutex.withLock { opsByKey[invoiceHash] } ?: return - withTimeoutOrNull(LightningRepo.SEND_LN_TIMEOUT) { - current.settled.await() + val match = rows?.let { + pickMatch( + QuickPayLedgerRecord( + id = invoiceHash, + amountCents = 0L, + dayKey = "", + invoicePaymentHash = invoiceHash, + paymentId = op.paymentId, + phase = QuickPayRecordPhase.SUBMITTED, + ), + it, + ) } - mutex.withLock { - val live = opsByKey[invoiceHash] ?: return@withLock - if (live.settled.isCompleted || live.emitted) return@withLock - val attachedId = live.sessionId - if (attachedId != null) { - live.emitted = true - pendingPaymentRepo.track(invoiceHash) - emitToSession( - attachedId, - QuickPaySessionEvent.Pending( - paymentHash = invoiceHash, - amount = invoice.amountSats.toLong(), - paymentRequest = invoice.bolt11, - ), - ) - } + if (match?.status == QuickPayReconcileRow.Status.SUCCEEDED) { + emitSuccessLocked(op, feePaidMsat = null) + } else { + emitErrorLocked( + op, + QuickPayPaymentFailedError( + paymentHash = invoiceHash, + reason = null, + paymentRequest = op.paymentRequest, + ), + op.paymentRequest, + ) } - } catch (e: CancellationException) { - mutex.withLock { - val current = opsByKey[invoiceHash] - if (current == null || current.dispatched) return@withLock - writeLedger { ledger, _ -> releaseRecord(ledger, invoiceHash) } - removeOpLocked(current) + removeOpLocked(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) { + markSubmittedLocked(invoiceHash, paymentId) + mutex.withLock { + val current = opsByKey[invoiceHash] ?: return@withLock + current.paymentId = paymentId + if (paymentId.isNotBlank() && paymentId != invoiceHash) { + opsByKey[paymentId] = current } - throw e + } + } + + private suspend fun onInvoiceRejected( + invoiceHash: String, + paymentRequest: String, + error: Throwable, + ) { + if (error is PaymentAbortedBeforeSend) { + releaseIfNotDispatched(invoiceHash) + return + } + handleDispatchError(invoiceHash, paymentRequest, error) + } + + private suspend fun awaitTerminalOrPending(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 + writeLedger { ledger, _ -> releaseRecord(ledger, invoiceHash) } + removeOpLocked(current) } } @@ -365,9 +373,7 @@ class QuickPayRepo @Inject constructor( } } - private fun parseInvoiceHash(bolt11: String): String? { - return runCatching { Bolt11Invoice.fromStr(bolt11).paymentHash() }.getOrNull() - } + private fun parseInvoiceHash(bolt11: String): String? = invoiceHashParser(bolt11) private suspend fun handleDispatchError( invoiceHash: String, @@ -375,42 +381,56 @@ class QuickPayRepo @Inject constructor( error: Throwable, ) { when (classifyDispatchError(error)) { - QuickPayDispatchClass.PRE_DISPATCH_REJECTION, - QuickPayDispatchClass.DUPLICATE_PAYMENT, - -> { + QuickPayDispatchClass.PRE_DISPATCH_REJECTION -> { mutex.withLock { - val outcome = noteTerminalLocked( + noteTerminalLocked( paymentId = null, paymentHash = invoiceHash, success = false, ) - emitOutcome(outcome, invoiceHash, error, paymentRequest) + emitOutcome(invoiceHash, error, paymentRequest) } } - QuickPayDispatchClass.AMBIGUOUS -> { - val rows = lightningRepo.listPaymentsOrNull()?.map { QuickPayReconcileRow(it) } + QuickPayDispatchClass.DUPLICATE_PAYMENT, + QuickPayDispatchClass.AMBIGUOUS, + -> { + val rows = loadPaymentRows() mutex.withLock { - val record = currentLedger()?.recordMatching(invoiceHash) - if (record != null && rows != null) { - applyAmbiguousLookupLocked(record, rows) - } - val remaining = currentLedger()?.recordMatching(invoiceHash) - val op = opsByKey[invoiceHash] - if (remaining == null) { - op?.let { removeOpLocked(it) } - } else { - op?.dispatched = true - } - emitToSession( - op?.sessionId, - QuickPaySessionEvent.Error(error, paymentRequest), - ) - op?.emitted = true + settleAmbiguousLocked(invoiceHash, paymentRequest, error, rows) } } } } + private suspend fun settleAmbiguousLocked( + invoiceHash: String, + paymentRequest: String, + error: Throwable, + rows: List?, + ) { + val record = currentLedger()?.recordMatching(invoiceHash) + val applied = if (record != null && rows != null) { + applyAmbiguousLookupLocked(record, rows) + } else { + AmbiguousApply.UNCHANGED + } + val remaining = currentLedger()?.recordMatching(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) @@ -484,16 +504,12 @@ class QuickPayRepo @Inject constructor( kind = kind, invoicePaymentHash = record.invoicePaymentHash, ) - if (op != null && !op.emitted) { - op.emitted = true - val event = if (success) { - val feeSats = msatFloorOf(feePaidMsat ?: 0u) - QuickPaySessionEvent.Success( - paymentHash = record.invoicePaymentHash, - amountWithFee = (op.displaySats.safe() + feeSats.safe()).toLong(), - ) + if (op != null) { + if (success) { + emitSuccessLocked(op, feePaidMsat) } else { - QuickPaySessionEvent.Error( + emitErrorLocked( + op, QuickPayPaymentFailedError( paymentHash = record.invoicePaymentHash, reason = failureReason, @@ -502,12 +518,7 @@ class QuickPayRepo @Inject constructor( op.paymentRequest, ) } - emitToSession(op.sessionId, event) - op.settled.complete(Unit) removeOpLocked(op) - } else { - op?.settled?.complete(Unit) - op?.let { removeOpLocked(it) } } return outcome } @@ -537,15 +548,16 @@ class QuickPayRepo @Inject constructor( private suspend fun applyAmbiguousLookupLocked( record: QuickPayLedgerRecord, rows: List, - ) { - val match = pickMatch(record, rows) ?: return - when (match.status) { - QuickPayReconcileRow.Status.PENDING -> Unit + ): AmbiguousApply { + val match = pickMatch(record, rows) ?: return AmbiguousApply.UNCHANGED + return when (match.status) { + QuickPayReconcileRow.Status.PENDING -> AmbiguousApply.UNCHANGED QuickPayReconcileRow.Status.SUCCEEDED -> { writeLedger { ledger, _ -> val index = ledger.recordIndex(record.invoicePaymentHash) ?: return@writeLedger ledger ledger.copy(records = ledger.records.toMutableList().also { it.removeAt(index) }) } + AmbiguousApply.SUCCEEDED } QuickPayReconcileRow.Status.FAILED -> { val attributed = isAttributedFailure( @@ -555,9 +567,10 @@ class QuickPayRepo @Inject constructor( match.invoicePaymentHash, ) if (!attributed) { - return + return AmbiguousApply.UNCHANGED } writeLedger { ledger, _ -> releaseRecord(ledger, record.invoicePaymentHash) } + AmbiguousApply.FAILED } } } @@ -651,6 +664,29 @@ class QuickPayRepo @Inject constructor( return supported } + 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 } @@ -661,21 +697,108 @@ class QuickPayRepo @Inject constructor( } private fun emitOutcome( - outcome: QuickPayTerminalOutcome, invoiceHash: String, error: Throwable, paymentRequest: String, ) { - val op = opsByKey[invoiceHash] - if (op != null && !op.emitted) { - op.emitted = true - emitToSession(op.sessionId, QuickPaySessionEvent.Error(error, paymentRequest)) + 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() } - op?.let { removeOpLocked(it) } - if (outcome == QuickPayTerminalOutcome.None) { - emitToSession(op?.sessionId, QuickPaySessionEvent.Error(error, paymentRequest)) + val amountCents = quickPayReserveCents(converted.toUsdCents(), settings.quickPayAmount, amountSats) + val capCents = quickPayCapCents(settings.quickPayAmount, settings.quickPayDailyLimitMultiplier) + return PreparedReserve(amountCents, capCents) + } + + private suspend fun writeReserveLocked( + paymentHash: String, + prepared: PreparedReserve, + ): 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 - prepared.amountCents) return@writeLedger ledger + val total = spend.spentCents + prepared.amountCents + if (total > prepared.capCents) return@writeLedger ledger + val next = ledger.pruned(spend.dayKey) + val record = QuickPayLedgerRecord( + id = UUID.randomUUID().toString(), + amountCents = prepared.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 + } + + private suspend fun loadPaymentRows(): List? { + paymentRows?.let { return it() } + return lightningRepo.listPaymentsOrNull()?.map { QuickPayReconcileRow(it) } } private fun emitToSession(sessionId: String?, event: QuickPaySessionEvent) { @@ -711,12 +834,18 @@ class QuickPayRepo @Inject constructor( val supported: Boolean, val ledger: QuickPayLedger?, ) + + 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 fun classifyDispatchError(error: Throwable): QuickPayDispatchClass { - if (PrivatePaykitErrorClassifier.isDuplicatePaymentError(error)) { - return QuickPayDispatchClass.DUPLICATE_PAYMENT - } return when (error.asNodeException()) { is NodeException.InvalidInvoice, is NodeException.InvalidAmount, diff --git a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt index 1ccf9c8c6..542f78ec5 100644 --- a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt @@ -3,8 +3,12 @@ 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.flow.MutableStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Before @@ -13,8 +17,10 @@ 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 @@ -32,15 +38,21 @@ 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 context = ApplicationProvider.getApplicationContext() private val cacheStore = CacheStore(context) private val settingsStore: SettingsStore = mock() @@ -84,6 +96,7 @@ class QuickPayRepoTest : BaseUnitTest() { ioDispatcher = testDispatcher, clock = clock, ) + sut.invoiceHashParser = { bolt11 -> bolt11.takeIf { it == TEST_BOLT11 }?.let { TEST_HASH } } } @After @@ -275,7 +288,30 @@ class QuickPayRepoTest : BaseUnitTest() { @Test fun `android ledger fixture decodes`() { - val raw = javaClass.getResource("/quickpay/android-ledger.json")!!.readText() + val raw = """{ + "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() val ledger = json.decodeFromString(raw) assertEquals("pid-android", ledger.records.first().paymentId) assertEquals(QuickPayRecordPhase.SUBMITTED, ledger.records.first().phase) @@ -354,6 +390,109 @@ class QuickPayRepoTest : BaseUnitTest() { 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")) + sut.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 keeps spend and emits success`() = test { + val (bolt11, hash) = testInvoice() + stubPayInvoiceFailure(NodeException.DuplicatePayment("dup")) + sut.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(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")) + sut.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 `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 { + 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() + reloaded.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()) + } + private suspend fun spentCents(): Long = cacheStore.data.first().quickPayLedger?.spentCents ?: 0L @@ -370,6 +509,41 @@ class QuickPayRepoTest : BaseUnitTest() { } } + private suspend fun stubPayInvoiceFailure(error: NodeException) { + whenever { lightningRepo.payInvoice(any(), anyOrNull(), any()) } + .thenReturn(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 { + val repo = QuickPayRepo( + cacheStore = cacheStore, + settingsStore = settingsStore, + currencyRepo = currencyRepo, + lightningRepo = lightningRepo, + pendingPaymentRepo = pendingPaymentRepo, + ioDispatcher = testDispatcher, + clock = clock, + ) + repo.invoiceHashParser = { bolt11 -> bolt11.takeIf { it == TEST_BOLT11 }?.let { TEST_HASH } } + return repo + } + private fun stubZeroCentConversion(dustSats: Long) { whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())).thenAnswer { invocation -> val sats = invocation.getArgument(0) From cf4bb0ac24d63772957f38b7326df30238b14be3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 22 Aug 2026 23:05:55 +0200 Subject: [PATCH 36/71] refactor: extract quickpay coordinator --- .../repositories/QuickPayCoordinator.kt | 1014 +++++++++++++++++ .../to/bitkit/repositories/QuickPayRepo.kt | 963 +--------------- .../bitkit/repositories/QuickPayRepoTest.kt | 24 +- 3 files changed, 1044 insertions(+), 957 deletions(-) create mode 100644 app/src/main/java/to/bitkit/repositories/QuickPayCoordinator.kt diff --git a/app/src/main/java/to/bitkit/repositories/QuickPayCoordinator.kt b/app/src/main/java/to/bitkit/repositories/QuickPayCoordinator.kt new file mode 100644 index 000000000..e40d4b04c --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/QuickPayCoordinator.kt @@ -0,0 +1,1014 @@ +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.Bolt11Invoice +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.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 + +@Singleton +class QuickPayCoordinator @Inject constructor( + cacheStore: CacheStore, + private val settingsStore: SettingsStore, + private val currencyRepo: CurrencyRepo, + private val lightningRepo: LightningRepo, + private val pendingPaymentRepo: PendingPaymentRepo, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + clock: Clock, +) { + companion object { + private const val TAG = "QuickPayCoordinator" + private fun hashFromBolt11(bolt11: String): String? { + return runCatching { Bolt11Invoice.fromStr(bolt11).paymentHash() }.getOrNull() + } + } + + private val spend = QuickPaySpendStore(cacheStore, clock) + private val scope = appScope(ioDispatcher, TAG) + private val mutex = Mutex() + private val opsByKey = mutableMapOf() + private val sessionFlows = ConcurrentHashMap>() + internal var invoiceHashParser: (String) -> String? = Companion::hashFromBolt11 + internal var paymentRows: (() -> List?)? = null + + 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 + } + } + + 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) } + } + } + + suspend fun noteTerminal( + paymentId: String?, + paymentHash: String?, + success: Boolean, + feePaidMsat: ULong? = null, + failureReason: PaymentFailureReason? = null, + ): QuickPayTerminalOutcome = withContext(ioDispatcher) { + mutex.withLock { + noteTerminalLocked( + paymentId = paymentId, + paymentHash = paymentHash, + success = success, + feePaidMsat = feePaidMsat, + failureReason = failureReason, + ) + } + } + + 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 -> return + PreparePayResult.REJECTED -> return + PreparePayResult.RECOVERED -> settleRecovered(invoiceHash) + PreparePayResult.FRESH -> { + dispatchBolt11(invoice, invoiceHash) + awaitTerminalOrPending(invoiceHash) + } + } + } + + private fun invoiceHashOrEmit(session: QuickPaySession, invoice: ResolvedInvoice): String? { + val invoiceHash = invoiceHashParser(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 + } + if (prepared == null || spend.reserve(invoiceHash, prepared.amountCents, prepared.capCents) == 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 { + reconcileLocked(rows) + val op = opsByKey[invoiceHash] ?: return@withLock + if (spend.matching(invoiceHash) != null) { + emitPendingLocked(op) + return@withLock + } + val match = rows?.let { + pickMatch( + QuickPayLedgerRecord( + id = invoiceHash, + amountCents = 0L, + dayKey = "", + invoicePaymentHash = invoiceHash, + paymentId = op.paymentId, + phase = QuickPayRecordPhase.SUBMITTED, + ), + it, + ) + } + if (match?.status == QuickPayReconcileRow.Status.SUCCEEDED) { + emitSuccessLocked(op, feePaidMsat = null) + } else { + emitErrorLocked( + op, + QuickPayPaymentFailedError( + paymentHash = invoiceHash, + reason = null, + paymentRequest = op.paymentRequest, + ), + op.paymentRequest, + ) + } + removeOpLocked(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 awaitTerminalOrPending(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 (classifyDispatchError(error)) { + QuickPayDispatchClass.PRE_DISPATCH_REJECTION -> { + mutex.withLock { + noteTerminalLocked( + paymentId = null, + paymentHash = invoiceHash, + success = false, + ) + emitOutcome(invoiceHash, error, paymentRequest) + } + } + QuickPayDispatchClass.DUPLICATE_PAYMENT, + QuickPayDispatchClass.AMBIGUOUS, + -> { + val rows = loadPaymentRows() + mutex.withLock { + settleAmbiguousLocked(invoiceHash, paymentRequest, error, rows) + } + } + } + } + + private suspend fun settleAmbiguousLocked( + invoiceHash: String, + paymentRequest: String, + error: Throwable, + rows: List?, + ) { + val record = spend.matching(invoiceHash) + val applied = if (record != null && rows != null) { + applyAmbiguousLookupLocked(record, rows) + } 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 noteTerminalLocked( + paymentId: String?, + paymentHash: String?, + success: Boolean, + feePaidMsat: ULong? = null, + failureReason: PaymentFailureReason? = null, + ): QuickPayTerminalOutcome { + val keys = listOfNotNull(paymentId, paymentHash).filter { it.isNotBlank() } + if (keys.isEmpty()) return QuickPayTerminalOutcome.None + + val snapshot = spend.snapshot() + if (!snapshot.supported) return QuickPayTerminalOutcome.None + val ledger = snapshot.ledger ?: return QuickPayTerminalOutcome.None + val index = keys.firstNotNullOfOrNull { ledger.recordIndex(it) } ?: return QuickPayTerminalOutcome.None + val record = ledger.records[index] + val op = opsByKey[record.invoicePaymentHash] ?: record.paymentId?.let { opsByKey[it] } + if (!success && !isAttributedFailure(record, op, paymentId, paymentHash)) { + return QuickPayTerminalOutcome.None + } + + spend.settle(keys, success) + + val kind = if (success) { + QuickPayTerminalKind.SETTLED_SUCCESS + } else { + QuickPayTerminalKind.SETTLED_FAILURE + } + val outcome = QuickPayTerminalOutcome( + 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, + ): AmbiguousApply { + val match = pickMatch(record, rows) ?: return AmbiguousApply.UNCHANGED + return when (match.status) { + QuickPayReconcileRow.Status.PENDING -> AmbiguousApply.UNCHANGED + QuickPayReconcileRow.Status.SUCCEEDED -> { + 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 + .filter { !it.dispatched } + .map { it.invoiceHash } + .toSet() + spend.applyReconcile(rows, live) { record, match -> + isAttributedFailure(record, opsByKey[record.invoicePaymentHash], match.paymentId, match.invoicePaymentHash) + } + } + + private fun pickMatch( + 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 + } + } + } + + 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? { + paymentRows?.let { return it() } + return lightningRepo.listPaymentsOrNull()?.map { QuickPayReconcileRow(it) } + } + + 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(dayKey, 0L, supported = false, ledger = ledger) + val spend = spendFor(ledger, dayKey) + return SpendSnapshot(spend.dayKey, 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, + ): 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) + 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) + } + } + + suspend fun applyReconcile( + rows: List?, + liveSubmittingHashes: Set, + shouldReleaseFailed: (QuickPayLedgerRecord, QuickPayReconcileRow) -> Boolean, + ) { + if (rows == null) return + writeLedger { ledger, dayKey -> + val next = ledger.pruned(dayKey) + val remaining = mutableListOf() + var spent = next.spentCents + for (record in next.records) { + if (liveSubmittingHashes.contains(record.invoicePaymentHash)) { + remaining.add(record) + continue + } + val match = pickReconcileMatch(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 dayKey: String, + 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): QuickPayLedger { + if (currentDay.isEmpty()) return this + return copy(records = records.filter { it.dayKey >= currentDay }) +} + +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 pickReconcileMatch( + 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/repositories/QuickPayRepo.kt b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt index a1b8bd420..f29c9c2f4 100644 --- a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt @@ -1,146 +1,34 @@ 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.Bolt11Invoice -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 @Singleton -@Suppress("LongParameterList", "LargeClass") class QuickPayRepo @Inject constructor( - private val cacheStore: CacheStore, - private val settingsStore: SettingsStore, - private val currencyRepo: CurrencyRepo, - private val lightningRepo: LightningRepo, - private val pendingPaymentRepo: PendingPaymentRepo, - @IoDispatcher private val ioDispatcher: CoroutineDispatcher, - private val clock: Clock, + private val coordinator: QuickPayCoordinator, ) { companion object { - private const val TAG = "QuickPayRepo" - const val LEDGER_VERSION = 1 - - private fun hashFromBolt11(bolt11: String): String? { - return runCatching { Bolt11Invoice.fromStr(bolt11).paymentHash() }.getOrNull() - } - } - - private val scope = appScope(ioDispatcher, TAG) - private val mutex = Mutex() - private val opsByKey = mutableMapOf() - private val sessionFlows = ConcurrentHashMap>() - internal var invoiceHashParser: (String) -> String? = Companion::hashFromBolt11 - internal var paymentRows: (() -> List?)? = null - - init { - scope.launch { - lightningRepo.lightningState - .map { it.nodeLifecycleState.isRunning() to it.isSyncHealthy } - .distinctUntilChanged() - .collect { (running, _) -> - if (running) reconcileAgainstLdk() - } - } + const val LEDGER_VERSION = QuickPaySpendStore.LEDGER_VERSION } - 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 attach(session: QuickPaySession): Flow = coordinator.attach(session) - fun detachAll() { - scope.launch { - val ids = sessionFlows.keys.toList() - ids.forEach { detachSession(it) } - } - } + fun detach(session: QuickPaySession) = coordinator.detach(session) - fun pay(session: QuickPaySession, request: QuickPayPayRequest) { - scope.launch { payNow(session, request) } - } + fun detachAll() = coordinator.detachAll() - suspend fun canApply(amountSats: ULong): Result = withContext(ioDispatcher) { - runSuspendCatching { - val settings = settingsStore.data.first() - if (!settings.isQuickPayEnabled || amountSats == 0uL) return@runSuspendCatching false + fun pay(session: QuickPaySession, request: QuickPayPayRequest) = coordinator.pay(session, request) - 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 spend = mutex.withLock { currentSpend() } - if (!spend.supported) return@runSuspendCatching false - if (spend.spentCents + reserveCents <= capCents) return@runSuspendCatching true - - Logger.info( - "Skipping QuickPay: daily spend '${spend.spentCents}' + '$reserveCents' exceeds cap '$capCents'", - context = TAG, - ) - false - } - } + suspend fun canApply(amountSats: ULong): Result = coordinator.canApply(amountSats) 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 { writeReserveLocked(paymentHash, prepared) } - } - } + ): Result = coordinator.reserveBound(paymentHash, amountSats) suspend fun noteTerminal( paymentId: String?, @@ -148,720 +36,32 @@ class QuickPayRepo @Inject constructor( success: Boolean, feePaidMsat: ULong? = null, failureReason: PaymentFailureReason? = null, - ): QuickPayTerminalOutcome = withContext(ioDispatcher) { - mutex.withLock { - noteTerminalLocked( - paymentId = paymentId, - paymentHash = paymentHash, - success = success, - feePaidMsat = feePaidMsat, - failureReason = failureReason, - ) - } - } - - suspend fun reconcileAgainstLdk() { - val rows = loadPaymentRows() - mutex.withLock { - val live = opsByKey.values - .filter { !it.dispatched } - .map { it.invoiceHash } - .toSet() - reconcileLocked(rows, live) - } - } - - 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 -> return - PreparePayResult.REJECTED -> return - PreparePayResult.RECOVERED -> settleRecovered(invoiceHash) - PreparePayResult.FRESH -> { - dispatchBolt11(invoice, invoiceHash) - awaitTerminalOrPending(invoiceHash) - } - } - } - - private fun invoiceHashOrEmit(session: QuickPaySession, invoice: ResolvedInvoice): String? { - val invoiceHash = parseInvoiceHash(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 = currentLedger()?.recordMatching(invoiceHash) - if (open != null) { - registerOp(recoveredOp(session, invoice, invoiceHash, open)) - return@withLock PreparePayResult.RECOVERED - } - if (prepared == null || writeReserveLocked(invoiceHash, prepared) == null) { - rejectCap(session, invoice) - return@withLock PreparePayResult.REJECTED - } - if (sessionFlows[session.id] == null) { - writeLedger { ledger, _ -> releaseRecord(ledger, 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 live = opsByKey.values - .filter { !it.dispatched } - .map { it.invoiceHash } - .toSet() - reconcileLocked(rows, live) - val op = opsByKey[invoiceHash] ?: return@withLock - if (currentLedger()?.recordMatching(invoiceHash) != null) { - emitPendingLocked(op) - return@withLock - } - val match = rows?.let { - pickMatch( - QuickPayLedgerRecord( - id = invoiceHash, - amountCents = 0L, - dayKey = "", - invoicePaymentHash = invoiceHash, - paymentId = op.paymentId, - phase = QuickPayRecordPhase.SUBMITTED, - ), - it, - ) - } - if (match?.status == QuickPayReconcileRow.Status.SUCCEEDED) { - emitSuccessLocked(op, feePaidMsat = null) - } else { - emitErrorLocked( - op, - QuickPayPaymentFailedError( - paymentHash = invoiceHash, - reason = null, - paymentRequest = op.paymentRequest, - ), - op.paymentRequest, - ) - } - removeOpLocked(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) { - markSubmittedLocked(invoiceHash, paymentId) - mutex.withLock { - 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 awaitTerminalOrPending(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) - } - } + ): QuickPayTerminalOutcome = coordinator.noteTerminal( + paymentId = paymentId, + paymentHash = paymentHash, + success = success, + feePaidMsat = feePaidMsat, + failureReason = failureReason, + ) - private suspend fun releaseIfNotDispatched(invoiceHash: String) { - mutex.withLock { - val current = opsByKey[invoiceHash] - if (current == null || current.dispatched) return@withLock - writeLedger { ledger, _ -> releaseRecord(ledger, invoiceHash) } - removeOpLocked(current) - } - } + suspend fun reconcileAgainstLdk() = coordinator.reconcileAgainstLdk() - private suspend fun resolveInvoice( + internal suspend fun payNow( 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 fun parseInvoiceHash(bolt11: String): String? = invoiceHashParser(bolt11) + ) = coordinator.payNow(session, request) - private suspend fun handleDispatchError( - invoiceHash: String, - paymentRequest: String, - error: Throwable, - ) { - when (classifyDispatchError(error)) { - QuickPayDispatchClass.PRE_DISPATCH_REJECTION -> { - mutex.withLock { - noteTerminalLocked( - paymentId = null, - paymentHash = invoiceHash, - success = false, - ) - emitOutcome(invoiceHash, error, paymentRequest) - } - } - QuickPayDispatchClass.DUPLICATE_PAYMENT, - QuickPayDispatchClass.AMBIGUOUS, - -> { - val rows = loadPaymentRows() - mutex.withLock { - settleAmbiguousLocked(invoiceHash, paymentRequest, error, rows) - } - } + internal var invoiceHashParser: (String) -> String? + get() = coordinator.invoiceHashParser + set(value) { + coordinator.invoiceHashParser = value } - } - private suspend fun settleAmbiguousLocked( - invoiceHash: String, - paymentRequest: String, - error: Throwable, - rows: List?, - ) { - val record = currentLedger()?.recordMatching(invoiceHash) - val applied = if (record != null && rows != null) { - applyAmbiguousLookupLocked(record, rows) - } else { - AmbiguousApply.UNCHANGED + internal var paymentRows: (() -> List?)? + get() = coordinator.paymentRows + set(value) { + coordinator.paymentRows = value } - val remaining = currentLedger()?.recordMatching(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() - writeLedger { ledger, _ -> releaseRecord(ledger, op.invoiceHash) } - removeOpLocked(op) - } - } - - private suspend fun markSubmittedLocked(invoiceHash: String, paymentId: String) { - mutex.withLock { - 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, - ) - }, - ) - } - } - } - - @Suppress("CyclomaticComplexMethod", "ReturnCount") - private suspend fun noteTerminalLocked( - paymentId: String?, - paymentHash: String?, - success: Boolean, - feePaidMsat: ULong? = null, - failureReason: PaymentFailureReason? = null, - ): QuickPayTerminalOutcome { - val keys = listOfNotNull(paymentId, paymentHash).filter { it.isNotBlank() } - if (keys.isEmpty()) return QuickPayTerminalOutcome.None - - val snapshot = currentSpend() - if (!snapshot.supported) return QuickPayTerminalOutcome.None - val ledger = snapshot.ledger ?: return QuickPayTerminalOutcome.None - val index = keys.firstNotNullOfOrNull { ledger.recordIndex(it) } ?: return QuickPayTerminalOutcome.None - val record = ledger.records[index] - val op = opsByKey[record.invoicePaymentHash] ?: record.paymentId?.let { opsByKey[it] } - if (!success && !isAttributedFailure(record, op, paymentId, paymentHash)) { - return QuickPayTerminalOutcome.None - } - - 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) - } - - val kind = if (success) { - QuickPayTerminalKind.SETTLED_SUCCESS - } else { - QuickPayTerminalKind.SETTLED_FAILURE - } - val outcome = QuickPayTerminalOutcome( - 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, - ): AmbiguousApply { - val match = pickMatch(record, rows) ?: return AmbiguousApply.UNCHANGED - return when (match.status) { - QuickPayReconcileRow.Status.PENDING -> AmbiguousApply.UNCHANGED - QuickPayReconcileRow.Status.SUCCEEDED -> { - writeLedger { ledger, _ -> - val index = ledger.recordIndex(record.invoicePaymentHash) ?: return@writeLedger ledger - ledger.copy(records = ledger.records.toMutableList().also { it.removeAt(index) }) - } - AmbiguousApply.SUCCEEDED - } - QuickPayReconcileRow.Status.FAILED -> { - val attributed = isAttributedFailure( - record, - opsByKey[record.invoicePaymentHash], - match.paymentId, - match.invoicePaymentHash, - ) - if (!attributed) { - return AmbiguousApply.UNCHANGED - } - writeLedger { ledger, _ -> releaseRecord(ledger, record.invoicePaymentHash) } - AmbiguousApply.FAILED - } - } - } - - @Suppress("LoopWithTooManyJumpStatements") - private suspend fun reconcileLocked( - rows: List?, - liveSubmittingHashes: Set, - ) { - if (rows == null) return - writeLedger { ledger, dayKey -> - var next = ledger.pruned(dayKey) - val remaining = mutableListOf() - var spent = next.spentCents - for (record in next.records) { - if (liveSubmittingHashes.contains(record.invoicePaymentHash)) { - remaining.add(record) - continue - } - val match = pickMatch(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 -> { - val op = opsByKey[record.invoicePaymentHash] - if (!isAttributedFailure(record, op, match.paymentId, match.invoicePaymentHash)) { - remaining.add(record) - } else if (record.dayKey == next.dayKey) { - spent = (spent - record.amountCents).coerceAtLeast(0L) - } - } - } - } - next.copy(records = remaining, spentCents = spent) - } - } - - private fun pickMatch( - 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 - } - } - } - - private suspend fun currentSpend(): SpendSnapshot { - val data = cacheStore.data.first() - val (ledger, supported) = data.resolvedLedger() - val dayKey = currentDayKey() - if (!supported) return SpendSnapshot(dayKey, 0L, supported = false, ledger = ledger) - val spend = spendFor(ledger, dayKey) - return SpendSnapshot(spend.dayKey, spend.spentCents, supported = true, ledger = ledger) - } - - private suspend fun currentLedger(): QuickPayLedger? { - val (ledger, supported) = cacheStore.data.first().resolvedLedger() - return ledger.takeIf { supported } - } - - 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 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 writeReserveLocked( - paymentHash: String, - prepared: PreparedReserve, - ): 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 - prepared.amountCents) return@writeLedger ledger - val total = spend.spentCents + prepared.amountCents - if (total > prepared.capCents) return@writeLedger ledger - val next = ledger.pruned(spend.dayKey) - val record = QuickPayLedgerRecord( - id = UUID.randomUUID().toString(), - amountCents = prepared.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 - } - - private suspend fun loadPaymentRows(): List? { - paymentRows?.let { return it() } - return lightningRepo.listPaymentsOrNull()?.map { QuickPayReconcileRow(it) } - } - - private fun emitToSession(sessionId: String?, event: QuickPaySessionEvent) { - if (sessionId == null) return - sessionFlows[sessionId]?.tryEmit(event) - } - - private fun currentDayKey(): String = - clock.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.toString() - - 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 SpendSnapshot( - val dayKey: String, - val spentCents: Long, - val supported: Boolean, - val ledger: QuickPayLedger?, - ) - - 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 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 QuickPaySession(val id: String = UUID.randomUUID().toString()) @@ -917,56 +117,6 @@ data class QuickPayTerminalOutcome( } } -@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(), -) - -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 - }, - ) -} - class QuickPayConversionError : AppError("Currency conversion failed") class QuickPayPaymentFailedError( @@ -974,64 +124,3 @@ class QuickPayPaymentFailedError( val reason: PaymentFailureReason?, val paymentRequest: String?, ) : AppError(reason?.name) - -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 == QuickPayRepo.LEDGER_VERSION) - } - return QuickPayLedger( - version = QuickPayRepo.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): QuickPayLedger { - if (currentDay.isEmpty()) return this - return copy(records = records.filter { it.dayKey >= currentDay }) -} - -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) -} diff --git a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt index 542f78ec5..54518379f 100644 --- a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt @@ -87,16 +87,7 @@ class QuickPayRepoTest : BaseUnitTest() { locale = Locale.US, ) } - sut = QuickPayRepo( - cacheStore = cacheStore, - settingsStore = settingsStore, - currencyRepo = currencyRepo, - lightningRepo = lightningRepo, - pendingPaymentRepo = pendingPaymentRepo, - ioDispatcher = testDispatcher, - clock = clock, - ) - sut.invoiceHashParser = { bolt11 -> bolt11.takeIf { it == TEST_BOLT11 }?.let { TEST_HASH } } + sut = repo() } @After @@ -262,15 +253,7 @@ class QuickPayRepoTest : BaseUnitTest() { @Test fun `fresh repo does not reserve the same recovered hash`() = test { assertNotNull(sut.reserveBound("inv", 1000u).getOrThrow()) - val reloaded = QuickPayRepo( - cacheStore = cacheStore, - settingsStore = settingsStore, - currencyRepo = currencyRepo, - lightningRepo = lightningRepo, - pendingPaymentRepo = pendingPaymentRepo, - ioDispatcher = testDispatcher, - clock = clock, - ) + val reloaded = repo() assertNull(reloaded.reserveBound("inv", 1000u).getOrThrow()) verify(lightningRepo, never()).payInvoice(any(), anyOrNull(), any()) @@ -531,7 +514,7 @@ class QuickPayRepoTest : BaseUnitTest() { private fun testInvoice(): Pair = TEST_BOLT11 to TEST_HASH private fun repo(): QuickPayRepo { - val repo = QuickPayRepo( + val coordinator = QuickPayCoordinator( cacheStore = cacheStore, settingsStore = settingsStore, currencyRepo = currencyRepo, @@ -540,6 +523,7 @@ class QuickPayRepoTest : BaseUnitTest() { ioDispatcher = testDispatcher, clock = clock, ) + val repo = QuickPayRepo(coordinator) repo.invoiceHashParser = { bolt11 -> bolt11.takeIf { it == TEST_BOLT11 }?.let { TEST_HASH } } return repo } From dc7dabac4d0cb809742bdf99301754ec9203623f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 22 Aug 2026 23:07:44 +0200 Subject: [PATCH 37/71] refactor: inject quickpay invoice parser --- app/src/main/java/to/bitkit/di/RepoModule.kt | 14 +++++++++++ .../repositories/QuickPayCoordinator.kt | 23 ++++++++++--------- .../to/bitkit/repositories/QuickPayRepo.kt | 12 ---------- .../bitkit/repositories/QuickPayRepoTest.kt | 20 ++++++++++------ 4 files changed, 39 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/to/bitkit/di/RepoModule.kt b/app/src/main/java/to/bitkit/di/RepoModule.kt index 6cbcccb25..f08456fec 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/repositories/QuickPayCoordinator.kt b/app/src/main/java/to/bitkit/repositories/QuickPayCoordinator.kt index e40d4b04c..a39ca333c 100644 --- a/app/src/main/java/to/bitkit/repositories/QuickPayCoordinator.kt +++ b/app/src/main/java/to/bitkit/repositories/QuickPayCoordinator.kt @@ -17,7 +17,6 @@ import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import org.lightningdevkit.ldknode.Bolt11Invoice import org.lightningdevkit.ldknode.NodeException import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentDirection @@ -44,6 +43,14 @@ 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? +} + @Singleton class QuickPayCoordinator @Inject constructor( cacheStore: CacheStore, @@ -51,14 +58,13 @@ class QuickPayCoordinator @Inject constructor( 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 = "QuickPayCoordinator" - private fun hashFromBolt11(bolt11: String): String? { - return runCatching { Bolt11Invoice.fromStr(bolt11).paymentHash() }.getOrNull() - } } private val spend = QuickPaySpendStore(cacheStore, clock) @@ -66,8 +72,6 @@ class QuickPayCoordinator @Inject constructor( private val mutex = Mutex() private val opsByKey = mutableMapOf() private val sessionFlows = ConcurrentHashMap>() - internal var invoiceHashParser: (String) -> String? = Companion::hashFromBolt11 - internal var paymentRows: (() -> List?)? = null init { scope.launch { @@ -177,7 +181,7 @@ class QuickPayCoordinator @Inject constructor( } private fun invoiceHashOrEmit(session: QuickPaySession, invoice: ResolvedInvoice): String? { - val invoiceHash = invoiceHashParser(invoice.bolt11) + val invoiceHash = invoiceParser.parse(invoice.bolt11) if (invoiceHash != null) return invoiceHash emitToSession( session.id, @@ -665,10 +669,7 @@ class QuickPayCoordinator @Inject constructor( return PreparedReserve(amountCents, capCents) } - private suspend fun loadPaymentRows(): List? { - paymentRows?.let { return it() } - return lightningRepo.listPaymentsOrNull()?.map { QuickPayReconcileRow(it) } - } + private suspend fun loadPaymentRows(): List? = paymentLookup.rows() private fun emitToSession(sessionId: String?, event: QuickPaySessionEvent) { if (sessionId == null) return diff --git a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt index f29c9c2f4..f151bc6a2 100644 --- a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt @@ -50,18 +50,6 @@ class QuickPayRepo @Inject constructor( session: QuickPaySession, request: QuickPayPayRequest, ) = coordinator.payNow(session, request) - - internal var invoiceHashParser: (String) -> String? - get() = coordinator.invoiceHashParser - set(value) { - coordinator.invoiceHashParser = value - } - - internal var paymentRows: (() -> List?)? - get() = coordinator.paymentRows - set(value) { - coordinator.paymentRows = value - } } data class QuickPaySession(val id: String = UUID.randomUUID().toString()) diff --git a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt index 54518379f..a4c12c21c 100644 --- a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt @@ -64,12 +64,14 @@ class QuickPayRepoTest : BaseUnitTest() { 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) @@ -377,7 +379,7 @@ class QuickPayRepoTest : BaseUnitTest() { fun `duplicate payment with pending ldk does not refund`() = test { val (bolt11, hash) = testInvoice() stubPayInvoiceFailure(NodeException.DuplicatePayment("dup")) - sut.paymentRows = { listOf(pendingRow(hash)) } + paymentRows = listOf(pendingRow(hash)) val session = QuickPaySession() sut.attach(session).test { @@ -393,7 +395,7 @@ class QuickPayRepoTest : BaseUnitTest() { fun `duplicate payment with succeeded ldk keeps spend and emits success`() = test { val (bolt11, hash) = testInvoice() stubPayInvoiceFailure(NodeException.DuplicatePayment("dup")) - sut.paymentRows = { listOf(succeededRow(hash)) } + paymentRows = listOf(succeededRow(hash)) val session = QuickPaySession() sut.attach(session).test { @@ -409,7 +411,7 @@ class QuickPayRepoTest : BaseUnitTest() { fun `ambiguous pending emits pending and keeps spend`() = test { val (bolt11, hash) = testInvoice() stubPayInvoiceFailure(NodeException.PaymentSendingFailed("send")) - sut.paymentRows = { listOf(pendingRow(hash)) } + paymentRows = listOf(pendingRow(hash)) val session = QuickPaySession() sut.attach(session).test { @@ -464,7 +466,7 @@ class QuickPayRepoTest : BaseUnitTest() { val (bolt11, hash) = testInvoice() assertNotNull(sut.reserveBound(hash, 500u).getOrThrow()) val reloaded = repo() - reloaded.paymentRows = { listOf(succeededRow(hash)) } + paymentRows = listOf(succeededRow(hash)) val session = QuickPaySession() reloaded.attach(session).test { @@ -520,12 +522,16 @@ class QuickPayRepoTest : BaseUnitTest() { 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, ) - val repo = QuickPayRepo(coordinator) - repo.invoiceHashParser = { bolt11 -> bolt11.takeIf { it == TEST_BOLT11 }?.let { TEST_HASH } } - return repo + return QuickPayRepo(coordinator) } private fun stubZeroCentConversion(dustSats: Long) { From 3d861676bf5f945e09d34d9591fb16197bb28854 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 22 Aug 2026 23:09:05 +0200 Subject: [PATCH 38/71] refactor: rename quickpay terminal to completion --- .../repositories/QuickPayCoordinator.kt | 32 ++++++++-------- .../to/bitkit/repositories/QuickPayRepo.kt | 14 +++---- .../java/to/bitkit/viewmodels/AppViewModel.kt | 4 +- .../bitkit/repositories/QuickPayRepoTest.kt | 38 +++++++++---------- .../viewmodels/AppViewModelSendFlowTest.kt | 30 +++++++-------- .../viewmodels/QuickPayViewModelTest.kt | 6 +-- 6 files changed, 62 insertions(+), 62 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/QuickPayCoordinator.kt b/app/src/main/java/to/bitkit/repositories/QuickPayCoordinator.kt index a39ca333c..8cd4aef4c 100644 --- a/app/src/main/java/to/bitkit/repositories/QuickPayCoordinator.kt +++ b/app/src/main/java/to/bitkit/repositories/QuickPayCoordinator.kt @@ -143,15 +143,15 @@ class QuickPayCoordinator @Inject constructor( } } - suspend fun noteTerminal( + suspend fun signalCompletion( paymentId: String?, paymentHash: String?, success: Boolean, feePaidMsat: ULong? = null, failureReason: PaymentFailureReason? = null, - ): QuickPayTerminalOutcome = withContext(ioDispatcher) { + ): QuickPayCompletionOutcome = withContext(ioDispatcher) { mutex.withLock { - noteTerminalLocked( + signalCompletionLocked( paymentId = paymentId, paymentHash = paymentHash, success = success, @@ -175,7 +175,7 @@ class QuickPayCoordinator @Inject constructor( PreparePayResult.RECOVERED -> settleRecovered(invoiceHash) PreparePayResult.FRESH -> { dispatchBolt11(invoice, invoiceHash) - awaitTerminalOrPending(invoiceHash) + awaitCompletionOrPending(invoiceHash) } } } @@ -314,7 +314,7 @@ class QuickPayCoordinator @Inject constructor( handleDispatchError(invoiceHash, paymentRequest, error) } - private suspend fun awaitTerminalOrPending(invoiceHash: String) { + private suspend fun awaitCompletionOrPending(invoiceHash: String) { val current = mutex.withLock { opsByKey[invoiceHash] } ?: return withTimeoutOrNull(LightningRepo.SEND_LN_TIMEOUT) { current.settled.await() @@ -372,7 +372,7 @@ class QuickPayCoordinator @Inject constructor( when (classifyDispatchError(error)) { QuickPayDispatchClass.PRE_DISPATCH_REJECTION -> { mutex.withLock { - noteTerminalLocked( + signalCompletionLocked( paymentId = null, paymentHash = invoiceHash, success = false, @@ -435,34 +435,34 @@ class QuickPayCoordinator @Inject constructor( } @Suppress("CyclomaticComplexMethod", "ReturnCount") - private suspend fun noteTerminalLocked( + private suspend fun signalCompletionLocked( paymentId: String?, paymentHash: String?, success: Boolean, feePaidMsat: ULong? = null, failureReason: PaymentFailureReason? = null, - ): QuickPayTerminalOutcome { + ): QuickPayCompletionOutcome { val keys = listOfNotNull(paymentId, paymentHash).filter { it.isNotBlank() } - if (keys.isEmpty()) return QuickPayTerminalOutcome.None + if (keys.isEmpty()) return QuickPayCompletionOutcome.None val snapshot = spend.snapshot() - if (!snapshot.supported) return QuickPayTerminalOutcome.None - val ledger = snapshot.ledger ?: return QuickPayTerminalOutcome.None - val index = keys.firstNotNullOfOrNull { ledger.recordIndex(it) } ?: return QuickPayTerminalOutcome.None + 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 QuickPayTerminalOutcome.None + return QuickPayCompletionOutcome.None } spend.settle(keys, success) val kind = if (success) { - QuickPayTerminalKind.SETTLED_SUCCESS + QuickPayCompletionKind.SETTLED_SUCCESS } else { - QuickPayTerminalKind.SETTLED_FAILURE + QuickPayCompletionKind.SETTLED_FAILURE } - val outcome = QuickPayTerminalOutcome( + val outcome = QuickPayCompletionOutcome( kind = kind, invoicePaymentHash = record.invoicePaymentHash, ) diff --git a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt index f151bc6a2..70d82222d 100644 --- a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt @@ -30,13 +30,13 @@ class QuickPayRepo @Inject constructor( amountSats: ULong, ): Result = coordinator.reserveBound(paymentHash, amountSats) - suspend fun noteTerminal( + suspend fun signalCompletion( paymentId: String?, paymentHash: String?, success: Boolean, feePaidMsat: ULong? = null, failureReason: PaymentFailureReason? = null, - ): QuickPayTerminalOutcome = coordinator.noteTerminal( + ): QuickPayCompletionOutcome = coordinator.signalCompletion( paymentId = paymentId, paymentHash = paymentHash, success = success, @@ -88,20 +88,20 @@ sealed interface QuickPaySessionEvent { ) : QuickPaySessionEvent } -enum class QuickPayTerminalKind { +enum class QuickPayCompletionKind { NONE, SETTLED_SUCCESS, SETTLED_FAILURE, } -data class QuickPayTerminalOutcome( - val kind: QuickPayTerminalKind = QuickPayTerminalKind.NONE, +data class QuickPayCompletionOutcome( + val kind: QuickPayCompletionKind = QuickPayCompletionKind.NONE, val invoicePaymentHash: String? = null, ) { - val wasQuickPay: Boolean get() = kind != QuickPayTerminalKind.NONE + val wasQuickPay: Boolean get() = kind != QuickPayCompletionKind.NONE companion object { - val None = QuickPayTerminalOutcome() + val None = QuickPayCompletionOutcome() } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 18c397820..712c12196 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1161,7 +1161,7 @@ class AppViewModel @Inject constructor( private suspend fun handlePaymentFailed(event: Event.PaymentFailed) { event.paymentHash?.let { paymentHash -> activityRepo.handlePaymentEvent(paymentHash) - quickPayRepo.noteTerminal( + quickPayRepo.signalCompletion( paymentId = event.paymentId, paymentHash = paymentHash, success = false, @@ -1249,7 +1249,7 @@ class AppViewModel @Inject constructor( private suspend fun handlePaymentSuccessful(event: Event.PaymentSuccessful) { val paymentHash = event.paymentHash activityRepo.handlePaymentEvent(paymentHash) - val isQuickPay = quickPayRepo.noteTerminal( + val isQuickPay = quickPayRepo.signalCompletion( paymentId = event.paymentId, paymentHash = paymentHash, success = true, diff --git a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt index a4c12c21c..54e4dcc03 100644 --- a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt @@ -133,70 +133,70 @@ class QuickPayRepoTest : BaseUnitTest() { } @Test - fun `noteTerminal failure rolls back a reservation`() = test { + fun `signalCompletion failure rolls back a reservation`() = test { assertNotNull(sut.reserveBound("abc", 1000u).getOrThrow()) markSubmitted("abc", "pid") - val outcome = sut.noteTerminal(paymentId = "pid", paymentHash = "abc", success = false) + val outcome = sut.signalCompletion(paymentId = "pid", paymentHash = "abc", success = false) - assertEquals(QuickPayTerminalKind.SETTLED_FAILURE, outcome.kind) + assertEquals(QuickPayCompletionKind.SETTLED_FAILURE, outcome.kind) assertEquals(0L, spentCents()) assertTrue(cacheStore.data.first().quickPayLedger!!.records.isEmpty()) } @Test - fun `noteTerminal failure on a prior day does not decrement the new day`() = 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.noteTerminal(paymentId = "old-pid", paymentHash = "old", success = false) + sut.signalCompletion(paymentId = "old-pid", paymentHash = "old", success = false) assertEquals(400L, spentCents()) } @Test - fun `noteTerminal success keeps spend`() = test { + fun `signalCompletion success keeps spend`() = test { assertNotNull(sut.reserveBound("abc", 1000u).getOrThrow()) - val outcome = sut.noteTerminal(paymentId = "pid", paymentHash = "abc", success = true) + val outcome = sut.signalCompletion(paymentId = "pid", paymentHash = "abc", success = true) - assertEquals(QuickPayTerminalKind.SETTLED_SUCCESS, outcome.kind) + assertEquals(QuickPayCompletionKind.SETTLED_SUCCESS, outcome.kind) assertEquals(500L, spentCents()) assertTrue(cacheStore.data.first().quickPayLedger!!.records.isEmpty()) } @Test - fun `noteTerminal is idempotent`() = test { + fun `signalCompletion is idempotent`() = test { assertNotNull(sut.reserveBound("abc", 1000u).getOrThrow()) - sut.noteTerminal(paymentId = null, paymentHash = "abc", success = true) + sut.signalCompletion(paymentId = null, paymentHash = "abc", success = true) - val second = sut.noteTerminal(paymentId = null, paymentHash = "abc", success = true) + val second = sut.signalCompletion(paymentId = null, paymentHash = "abc", success = true) - assertEquals(QuickPayTerminalOutcome.None, second) + assertEquals(QuickPayCompletionOutcome.None, second) assertEquals(500L, spentCents()) } @Test fun `dual aliases settle one record`() = test { assertNotNull(sut.reserveBound("inv", 1000u).getOrThrow()) - sut.noteTerminal(paymentId = "pid", paymentHash = "other", success = true) + sut.signalCompletion(paymentId = "pid", paymentHash = "other", success = true) // paymentId was not stored yet; settle by invoice hash then alias - val first = sut.noteTerminal(paymentId = "pid", paymentHash = "inv", success = true) - val second = sut.noteTerminal(paymentId = "pid", paymentHash = "inv", success = false) + val first = sut.signalCompletion(paymentId = "pid", paymentHash = "inv", success = true) + val second = sut.signalCompletion(paymentId = "pid", paymentHash = "inv", success = false) - assertEquals(QuickPayTerminalKind.SETTLED_SUCCESS, first.kind) - assertEquals(QuickPayTerminalOutcome.None, second) + 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.noteTerminal(paymentId = "stale-pid", paymentHash = "other", success = false) + val outcome = sut.signalCompletion(paymentId = "stale-pid", paymentHash = "other", success = false) - assertEquals(QuickPayTerminalOutcome.None, outcome) + assertEquals(QuickPayCompletionOutcome.None, outcome) assertEquals(500L, spentCents()) assertEquals(1, cacheStore.data.first().quickPayLedger!!.records.size) } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 8982ba739..507aeef4c 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -230,8 +230,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(cacheStore.data).thenReturn(flowOf(AppCacheData())) whenever { quickPayRepo.canApply(any()) }.thenReturn(Result.success(false)) whenever { - quickPayRepo.noteTerminal(anyOrNull(), anyOrNull(), any(), anyOrNull(), anyOrNull()) - }.thenReturn(to.bitkit.repositories.QuickPayTerminalOutcome.None) + 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())) @@ -1727,7 +1727,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(pendingPaymentRepo).resolve(PendingPaymentResolution.Success(paymentHash)) verify(activityRepo).setContact(contactPublicKey = contactKey, forPaymentId = paymentHash) - verify(quickPayRepo).noteTerminal( + verify(quickPayRepo).signalCompletion( paymentId = "payment_id", paymentHash = paymentHash, success = true, @@ -1759,7 +1759,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { reason = PaymentFailureReason.RETRIES_EXHAUSTED, ) ) - verify(quickPayRepo).noteTerminal( + verify(quickPayRepo).signalCompletion( paymentId = "payment_id", paymentHash = paymentHash, success = false, @@ -1783,7 +1783,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) advanceUntilIdle() - verify(quickPayRepo).noteTerminal( + verify(quickPayRepo).signalCompletion( paymentId = "payment_id", paymentHash = paymentHash, success = false, @@ -1798,10 +1798,10 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val paymentHash = "restart_ok" whenever(pendingPaymentRepo.isPending(paymentHash)).thenReturn(false) whenever { - quickPayRepo.noteTerminal(anyOrNull(), anyOrNull(), any(), anyOrNull(), anyOrNull()) + quickPayRepo.signalCompletion(anyOrNull(), anyOrNull(), any(), anyOrNull(), anyOrNull()) }.thenReturn( - to.bitkit.repositories.QuickPayTerminalOutcome( - kind = to.bitkit.repositories.QuickPayTerminalKind.SETTLED_SUCCESS, + to.bitkit.repositories.QuickPayCompletionOutcome( + kind = to.bitkit.repositories.QuickPayCompletionKind.SETTLED_SUCCESS, invoicePaymentHash = paymentHash, ), ) @@ -1816,7 +1816,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) advanceUntilIdle() - verify(quickPayRepo).noteTerminal( + verify(quickPayRepo).signalCompletion( paymentId = "payment_id", paymentHash = paymentHash, success = true, @@ -1832,8 +1832,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(pendingPaymentRepo.isPending(paymentHash)).thenReturn(true) whenever(pendingPaymentRepo.isActive(paymentHash)).thenReturn(false) whenever { - quickPayRepo.noteTerminal(anyOrNull(), anyOrNull(), any(), anyOrNull(), anyOrNull()) - }.thenReturn(to.bitkit.repositories.QuickPayTerminalOutcome.None) + quickPayRepo.signalCompletion(anyOrNull(), anyOrNull(), any(), anyOrNull(), anyOrNull()) + }.thenReturn(to.bitkit.repositories.QuickPayCompletionOutcome.None) advanceUntilIdle() emitNodeEvent( @@ -1861,10 +1861,10 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(pendingPaymentRepo.isPending(paymentHash)).thenReturn(true) whenever(pendingPaymentRepo.isActive(paymentHash)).thenReturn(false) whenever { - quickPayRepo.noteTerminal(anyOrNull(), anyOrNull(), any(), anyOrNull(), anyOrNull()) + quickPayRepo.signalCompletion(anyOrNull(), anyOrNull(), any(), anyOrNull(), anyOrNull()) }.thenReturn( - to.bitkit.repositories.QuickPayTerminalOutcome( - kind = to.bitkit.repositories.QuickPayTerminalKind.SETTLED_SUCCESS, + to.bitkit.repositories.QuickPayCompletionOutcome( + kind = to.bitkit.repositories.QuickPayCompletionKind.SETTLED_SUCCESS, invoicePaymentHash = paymentHash, ), ) @@ -1888,7 +1888,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { amountWithFeeSats = 510L, ), ) - verify(quickPayRepo).noteTerminal( + verify(quickPayRepo).signalCompletion( paymentId = "payment_id", paymentHash = paymentHash, success = true, diff --git a/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt index 0940591d8..95c447ab2 100644 --- a/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt @@ -96,7 +96,7 @@ class QuickPayViewModelTest : BaseUnitTest() { session, QuickPayPayRequest.Bolt11(bolt11 = "lnbcrt1test", amountSats = 500u), ) - verify(quickPayRepo, never()).noteTerminal(any(), any(), any(), any(), any()) + verify(quickPayRepo, never()).signalCompletion(any(), any(), any(), any(), any()) } @Test @@ -136,12 +136,12 @@ class QuickPayViewModelTest : BaseUnitTest() { } @Test - fun `viewmodel has no settlement methods on the repo besides noteTerminal from events`() = 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()).noteTerminal(any(), any(), any(), any(), any()) + verify(quickPayRepo, never()).signalCompletion(any(), any(), any(), any(), any()) } } From 0c0318a2a41537f05561c9b5017a4baa571b20eb Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 22 Aug 2026 23:12:11 +0200 Subject: [PATCH 39/71] test: inline quickpay ledger fixtures --- .../bitkit/repositories/QuickPayRepoTest.kt | 70 ++++++++++++------- .../resources/quickpay/android-ledger.json | 23 ------ .../test/resources/quickpay/ios-ledger.json | 14 ---- 3 files changed, 43 insertions(+), 64 deletions(-) delete mode 100644 app/src/test/resources/quickpay/android-ledger.json delete mode 100644 app/src/test/resources/quickpay/ios-ledger.json diff --git a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt index 54e4dcc03..3b8f352bc 100644 --- a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt @@ -52,6 +52,47 @@ 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) @@ -263,8 +304,7 @@ class QuickPayRepoTest : BaseUnitTest() { @Test fun `ios ledger fixture decodes`() { - val raw = javaClass.getResource("/quickpay/ios-ledger.json")!!.readText() - val ledger = json.decodeFromString(raw) + 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) @@ -273,31 +313,7 @@ class QuickPayRepoTest : BaseUnitTest() { @Test fun `android ledger fixture decodes`() { - val raw = """{ - "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() - val ledger = json.decodeFromString(raw) + 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) diff --git a/app/src/test/resources/quickpay/android-ledger.json b/app/src/test/resources/quickpay/android-ledger.json deleted file mode 100644 index 2ebe67ac7..000000000 --- a/app/src/test/resources/quickpay/android-ledger.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "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" - } - ] -} diff --git a/app/src/test/resources/quickpay/ios-ledger.json b/app/src/test/resources/quickpay/ios-ledger.json deleted file mode 100644 index 33638a6bd..000000000 --- a/app/src/test/resources/quickpay/ios-ledger.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "version": 1, - "dayKey": "2026-08-15", - "spentCents": 250, - "records": [ - { - "id": "rec-ios", - "amountCents": 250, - "dayKey": "2026-08-15", - "invoicePaymentHash": "inv-ios", - "phase": "submitting" - } - ] -} From a8cef2abb8c45dc562af29b559bb71b53db4a816 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 22 Aug 2026 23:12:11 +0200 Subject: [PATCH 40/71] refactor: dedupe quickpay reconcile paths --- .../repositories/QuickPayCoordinator.kt | 31 +++---------------- 1 file changed, 4 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/QuickPayCoordinator.kt b/app/src/main/java/to/bitkit/repositories/QuickPayCoordinator.kt index 8cd4aef4c..0f3132aed 100644 --- a/app/src/main/java/to/bitkit/repositories/QuickPayCoordinator.kt +++ b/app/src/main/java/to/bitkit/repositories/QuickPayCoordinator.kt @@ -246,7 +246,7 @@ class QuickPayCoordinator @Inject constructor( return@withLock } val match = rows?.let { - pickMatch( + pickLedgerMatch( QuickPayLedgerRecord( id = invoiceHash, amountCents = 0L, @@ -511,7 +511,7 @@ class QuickPayCoordinator @Inject constructor( record: QuickPayLedgerRecord, rows: List, ): AmbiguousApply { - val match = pickMatch(record, rows) ?: return AmbiguousApply.UNCHANGED + val match = pickLedgerMatch(record, rows) ?: return AmbiguousApply.UNCHANGED return when (match.status) { QuickPayReconcileRow.Status.PENDING -> AmbiguousApply.UNCHANGED QuickPayReconcileRow.Status.SUCCEEDED -> { @@ -544,29 +544,6 @@ class QuickPayCoordinator @Inject constructor( } } - private fun pickMatch( - 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 - } - } - } - private fun recoveredOp( session: QuickPaySession, invoice: ResolvedInvoice, @@ -814,7 +791,7 @@ internal class QuickPaySpendStore( remaining.add(record) continue } - val match = pickReconcileMatch(record, rows) + val match = pickLedgerMatch(record, rows) if (match == null) { remaining.add(record) continue @@ -991,7 +968,7 @@ private fun releaseRecord(ledger: QuickPayLedger, paymentHash: String): QuickPay return ledger.copy(records = remaining, spentCents = spent) } -private fun pickReconcileMatch( +private fun pickLedgerMatch( record: QuickPayLedgerRecord, rows: List, ): QuickPayReconcileRow? { From 7294f6872a7f0e3cf5c364baa7f58b1b22b4bc78 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 22 Aug 2026 23:15:10 +0200 Subject: [PATCH 41/71] chore: self review --- app/src/main/java/to/bitkit/repositories/QuickPayCoordinator.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/to/bitkit/repositories/QuickPayCoordinator.kt b/app/src/main/java/to/bitkit/repositories/QuickPayCoordinator.kt index 0f3132aed..ed4cbabd6 100644 --- a/app/src/main/java/to/bitkit/repositories/QuickPayCoordinator.kt +++ b/app/src/main/java/to/bitkit/repositories/QuickPayCoordinator.kt @@ -52,6 +52,7 @@ fun interface QuickPayPaymentLookup { } @Singleton +@Suppress("LongParameterList") class QuickPayCoordinator @Inject constructor( cacheStore: CacheStore, private val settingsStore: SettingsStore, @@ -776,6 +777,7 @@ internal class QuickPaySpendStore( } } + @Suppress("LoopWithTooManyJumpStatements") suspend fun applyReconcile( rows: List?, liveSubmittingHashes: Set, From d7009df737f1be64105bf84ef3f2daa102abca56 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sun, 23 Aug 2026 16:44:48 +0200 Subject: [PATCH 42/71] refactor: fold quickpay coordinator into repo --- .../repositories/QuickPayCoordinator.kt | 994 ---------------- .../to/bitkit/repositories/QuickPayRepo.kt | 1025 ++++++++++++++++- .../bitkit/repositories/QuickPayRepoTest.kt | 3 +- 3 files changed, 984 insertions(+), 1038 deletions(-) delete mode 100644 app/src/main/java/to/bitkit/repositories/QuickPayCoordinator.kt diff --git a/app/src/main/java/to/bitkit/repositories/QuickPayCoordinator.kt b/app/src/main/java/to/bitkit/repositories/QuickPayCoordinator.kt deleted file mode 100644 index ed4cbabd6..000000000 --- a/app/src/main/java/to/bitkit/repositories/QuickPayCoordinator.kt +++ /dev/null @@ -1,994 +0,0 @@ -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.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? -} - -@Singleton -@Suppress("LongParameterList") -class QuickPayCoordinator @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 = "QuickPayCoordinator" - } - - 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 - } - } - - 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) } - } - } - - 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, - ) - } - } - - 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 -> return - PreparePayResult.REJECTED -> return - PreparePayResult.RECOVERED -> settleRecovered(invoiceHash) - PreparePayResult.FRESH -> { - dispatchBolt11(invoice, invoiceHash) - awaitCompletionOrPending(invoiceHash) - } - } - } - - 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 - } - if (prepared == null || spend.reserve(invoiceHash, prepared.amountCents, prepared.capCents) == 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 { - reconcileLocked(rows) - val op = opsByKey[invoiceHash] ?: return@withLock - if (spend.matching(invoiceHash) != null) { - emitPendingLocked(op) - return@withLock - } - val match = rows?.let { - pickLedgerMatch( - QuickPayLedgerRecord( - id = invoiceHash, - amountCents = 0L, - dayKey = "", - invoicePaymentHash = invoiceHash, - paymentId = op.paymentId, - phase = QuickPayRecordPhase.SUBMITTED, - ), - it, - ) - } - if (match?.status == QuickPayReconcileRow.Status.SUCCEEDED) { - emitSuccessLocked(op, feePaidMsat = null) - } else { - emitErrorLocked( - op, - QuickPayPaymentFailedError( - paymentHash = invoiceHash, - reason = null, - paymentRequest = op.paymentRequest, - ), - op.paymentRequest, - ) - } - removeOpLocked(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 (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, paymentRequest, error, rows) - } - } - } - } - - private suspend fun settleAmbiguousLocked( - invoiceHash: String, - paymentRequest: String, - error: Throwable, - rows: List?, - ) { - val record = spend.matching(invoiceHash) - val applied = if (record != null && rows != null) { - applyAmbiguousLookupLocked(record, rows) - } 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, - ): AmbiguousApply { - val match = pickLedgerMatch(record, rows) ?: return AmbiguousApply.UNCHANGED - return when (match.status) { - QuickPayReconcileRow.Status.PENDING -> AmbiguousApply.UNCHANGED - QuickPayReconcileRow.Status.SUCCEEDED -> { - 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 - .filter { !it.dispatched } - .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? = paymentLookup.rows() - - 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(dayKey, 0L, supported = false, ledger = ledger) - val spend = spendFor(ledger, dayKey) - return SpendSnapshot(spend.dayKey, 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, - ): 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) - 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) - 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 dayKey: String, - 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): QuickPayLedger { - if (currentDay.isEmpty()) return this - return copy(records = records.filter { it.dayKey >= currentDay }) -} - -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/repositories/QuickPayRepo.kt b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt index 70d82222d..34d28b2b6 100644 --- a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt @@ -1,55 +1,55 @@ 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 -@Singleton -class QuickPayRepo @Inject constructor( - private val coordinator: QuickPayCoordinator, -) { - companion object { - const val LEDGER_VERSION = QuickPaySpendStore.LEDGER_VERSION - } - - fun attach(session: QuickPaySession): Flow = coordinator.attach(session) - - fun detach(session: QuickPaySession) = coordinator.detach(session) - - fun detachAll() = coordinator.detachAll() - - fun pay(session: QuickPaySession, request: QuickPayPayRequest) = coordinator.pay(session, request) - - suspend fun canApply(amountSats: ULong): Result = coordinator.canApply(amountSats) - - suspend fun reserveBound( - paymentHash: String, - amountSats: ULong, - ): Result = coordinator.reserveBound(paymentHash, amountSats) - - suspend fun signalCompletion( - paymentId: String?, - paymentHash: String?, - success: Boolean, - feePaidMsat: ULong? = null, - failureReason: PaymentFailureReason? = null, - ): QuickPayCompletionOutcome = coordinator.signalCompletion( - paymentId = paymentId, - paymentHash = paymentHash, - success = success, - feePaidMsat = feePaidMsat, - failureReason = failureReason, - ) - - suspend fun reconcileAgainstLdk() = coordinator.reconcileAgainstLdk() +fun interface QuickPayInvoiceParser { + fun parse(bolt11: String): String? +} - internal suspend fun payNow( - session: QuickPaySession, - request: QuickPayPayRequest, - ) = coordinator.payNow(session, request) +fun interface QuickPayPaymentLookup { + suspend fun rows(): List? } data class QuickPaySession(val id: String = UUID.randomUUID().toString()) @@ -112,3 +112,944 @@ class QuickPayPaymentFailedError( 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 + } + } + + 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) } + } + } + + 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, + ) + } + } + + private 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 -> return + PreparePayResult.REJECTED -> return + PreparePayResult.RECOVERED -> settleRecovered(invoiceHash) + PreparePayResult.FRESH -> { + dispatchBolt11(invoice, invoiceHash) + awaitCompletionOrPending(invoiceHash) + } + } + } + + 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 + } + if (prepared == null || spend.reserve(invoiceHash, prepared.amountCents, prepared.capCents) == 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 { + reconcileLocked(rows) + val op = opsByKey[invoiceHash] ?: return@withLock + if (spend.matching(invoiceHash) != null) { + emitPendingLocked(op) + return@withLock + } + val match = rows?.let { + pickLedgerMatch( + QuickPayLedgerRecord( + id = invoiceHash, + amountCents = 0L, + dayKey = "", + invoicePaymentHash = invoiceHash, + paymentId = op.paymentId, + phase = QuickPayRecordPhase.SUBMITTED, + ), + it, + ) + } + if (match?.status == QuickPayReconcileRow.Status.SUCCEEDED) { + emitSuccessLocked(op, feePaidMsat = null) + } else { + emitErrorLocked( + op, + QuickPayPaymentFailedError( + paymentHash = invoiceHash, + reason = null, + paymentRequest = op.paymentRequest, + ), + op.paymentRequest, + ) + } + removeOpLocked(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 (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, paymentRequest, error, rows) + } + } + } + } + + private suspend fun settleAmbiguousLocked( + invoiceHash: String, + paymentRequest: String, + error: Throwable, + rows: List?, + ) { + val record = spend.matching(invoiceHash) + val applied = if (record != null && rows != null) { + applyAmbiguousLookupLocked(record, rows) + } 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, + ): AmbiguousApply { + val match = pickLedgerMatch(record, rows) ?: return AmbiguousApply.UNCHANGED + return when (match.status) { + QuickPayReconcileRow.Status.PENDING -> AmbiguousApply.UNCHANGED + QuickPayReconcileRow.Status.SUCCEEDED -> { + 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 + .filter { !it.dispatched } + .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? = paymentLookup.rows() + + 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, + ): 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) + 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) + 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): QuickPayLedger { + if (currentDay.isEmpty()) return this + return copy(records = records.filter { it.dayKey >= currentDay }) +} + +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/test/java/to/bitkit/repositories/QuickPayRepoTest.kt b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt index 3b8f352bc..17eee0808 100644 --- a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt @@ -532,7 +532,7 @@ class QuickPayRepoTest : BaseUnitTest() { private fun testInvoice(): Pair = TEST_BOLT11 to TEST_HASH private fun repo(): QuickPayRepo { - val coordinator = QuickPayCoordinator( + return QuickPayRepo( cacheStore = cacheStore, settingsStore = settingsStore, currencyRepo = currencyRepo, @@ -547,7 +547,6 @@ class QuickPayRepoTest : BaseUnitTest() { ioDispatcher = testDispatcher, clock = clock, ) - return QuickPayRepo(coordinator) } private fun stubZeroCentConversion(dustSats: Long) { From 673eab4c33450cfcf8bfc759cc4cf3c8ea568860 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sun, 23 Aug 2026 18:13:42 +0200 Subject: [PATCH 43/71] fix: keep live quickpay ops out of reconcile --- .../to/bitkit/repositories/QuickPayRepo.kt | 54 ++++++++----------- .../bitkit/repositories/QuickPayRepoTest.kt | 30 +++++++++++ 2 files changed, 52 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt index 34d28b2b6..0f4339e25 100644 --- a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt @@ -224,7 +224,7 @@ class QuickPayRepo @Inject constructor( } } - private suspend fun reconcileAgainstLdk() { + internal suspend fun reconcileAgainstLdk() { val rows = loadPaymentRows() mutex.withLock { reconcileLocked(rows) } } @@ -302,39 +302,32 @@ class QuickPayRepo @Inject constructor( private suspend fun settleRecovered(invoiceHash: String) { val rows = loadPaymentRows() mutex.withLock { - reconcileLocked(rows) val op = opsByKey[invoiceHash] ?: return@withLock - if (spend.matching(invoiceHash) != null) { + val record = spend.matching(invoiceHash) ?: run { emitPendingLocked(op) return@withLock } - val match = rows?.let { - pickLedgerMatch( - QuickPayLedgerRecord( - id = invoiceHash, - amountCents = 0L, - dayKey = "", - invoicePaymentHash = invoiceHash, - paymentId = op.paymentId, - phase = QuickPayRecordPhase.SUBMITTED, - ), - it, - ) - } - if (match?.status == QuickPayReconcileRow.Status.SUCCEEDED) { - emitSuccessLocked(op, feePaidMsat = null) - } else { - emitErrorLocked( - op, - QuickPayPaymentFailedError( + val match = rows?.let { pickLedgerMatch(record, it) } + when (match?.status) { + QuickPayReconcileRow.Status.SUCCEEDED -> { + signalCompletionLocked( + paymentId = record.paymentId, paymentHash = invoiceHash, - reason = null, - paymentRequest = op.paymentRequest, - ), - op.paymentRequest, - ) + 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) } - removeOpLocked(op) } } @@ -598,10 +591,7 @@ class QuickPayRepo @Inject constructor( } private suspend fun reconcileLocked(rows: List?) { - val live = opsByKey.values - .filter { !it.dispatched } - .map { it.invoiceHash } - .toSet() + val live = opsByKey.values.map { it.invoiceHash }.toSet() spend.applyReconcile(rows, live) { record, match -> isAttributedFailure(record, opsByKey[record.invoicePaymentHash], match.paymentId, match.invoicePaymentHash) } diff --git a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt index 17eee0808..b1ff4b830 100644 --- a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt @@ -494,6 +494,36 @@ class QuickPayRepoTest : BaseUnitTest() { verify(lightningRepo, never()).payInvoice(any(), anyOrNull(), any()) } + @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 From 9eabf4cb26a4e04df3d1066bc169ac3bd1e5c899 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sun, 23 Aug 2026 18:15:15 +0200 Subject: [PATCH 44/71] fix: refund duplicate quickpay reserve --- .../to/bitkit/repositories/QuickPayRepo.kt | 23 +++++++++++++++---- .../bitkit/repositories/QuickPayRepoTest.kt | 20 +++++++++++++++- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt index 0f4339e25..a828def19 100644 --- a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt @@ -425,7 +425,7 @@ class QuickPayRepo @Inject constructor( paymentRequest: String, error: Throwable, ) { - when (classifyDispatchError(error)) { + when (val kind = classifyDispatchError(error)) { QuickPayDispatchClass.PRE_DISPATCH_REJECTION -> { mutex.withLock { signalCompletionLocked( @@ -441,7 +441,13 @@ class QuickPayRepo @Inject constructor( -> { val rows = loadPaymentRows() mutex.withLock { - settleAmbiguousLocked(invoiceHash, paymentRequest, error, rows) + settleAmbiguousLocked( + invoiceHash = invoiceHash, + paymentRequest = paymentRequest, + error = error, + rows = rows, + duplicate = kind == QuickPayDispatchClass.DUPLICATE_PAYMENT, + ) } } } @@ -452,10 +458,11 @@ class QuickPayRepo @Inject constructor( paymentRequest: String, error: Throwable, rows: List?, + duplicate: Boolean, ) { val record = spend.matching(invoiceHash) val applied = if (record != null && rows != null) { - applyAmbiguousLookupLocked(record, rows) + applyAmbiguousLookupLocked(record, rows, duplicate) } else { AmbiguousApply.UNCHANGED } @@ -566,12 +573,20 @@ class QuickPayRepo @Inject constructor( 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 -> { - spend.drop(record.invoicePaymentHash) + if (duplicate && + record.phase == QuickPayRecordPhase.SUBMITTING && + record.paymentId == null + ) { + spend.release(record.invoicePaymentHash) + } else { + spend.drop(record.invoicePaymentHash) + } AmbiguousApply.SUCCEEDED } QuickPayReconcileRow.Status.FAILED -> { diff --git a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt index b1ff4b830..d31983163 100644 --- a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt @@ -408,7 +408,7 @@ class QuickPayRepoTest : BaseUnitTest() { } @Test - fun `duplicate payment with succeeded ldk keeps spend and emits success`() = 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)) @@ -419,6 +419,24 @@ class QuickPayRepoTest : BaseUnitTest() { 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()) } From 816dd98c1b4121e78123d4fb50cf16d691225a19 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sun, 23 Aug 2026 18:16:52 +0200 Subject: [PATCH 45/71] fix: settle quickpay refund without hash --- .../java/to/bitkit/repositories/LightningRepo.kt | 7 +++++-- .../main/java/to/bitkit/viewmodels/AppViewModel.kt | 12 ++++++------ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 290ae5c43..406e16127 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( diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 712c12196..7412f3b6f 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1159,14 +1159,14 @@ class AppViewModel @Inject constructor( } private suspend fun handlePaymentFailed(event: Event.PaymentFailed) { + quickPayRepo.signalCompletion( + paymentId = event.paymentId, + paymentHash = event.paymentHash, + success = false, + failureReason = event.reason, + ) event.paymentHash?.let { paymentHash -> activityRepo.handlePaymentEvent(paymentHash) - quickPayRepo.signalCompletion( - paymentId = event.paymentId, - paymentHash = paymentHash, - success = false, - failureReason = event.reason, - ) if (pendingPaymentRepo.isPending(paymentHash)) { clearPendingContactPaymentContext(paymentHash) pendingPaymentRepo.resolve(PendingPaymentResolution.Failure(paymentHash, event.reason)) From df06ff539512d17a9c8caf018d0b8005a83c705e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sun, 23 Aug 2026 18:21:30 +0200 Subject: [PATCH 46/71] test: pin quickpay dispatch boundary --- .../to/bitkit/repositories/QuickPayRepo.kt | 2 +- .../bitkit/repositories/QuickPayRepoTest.kt | 86 ++++++++++++++++++- 2 files changed, 84 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt index a828def19..39c8b0d36 100644 --- a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt @@ -195,7 +195,7 @@ class QuickPayRepo @Inject constructor( } } - suspend fun reserveBound( + internal suspend fun reserveBound( paymentHash: String, amountSats: ULong, ): Result = withContext(ioDispatcher) { diff --git a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt index d31983163..d7c837a62 100644 --- a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt @@ -6,10 +6,12 @@ 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 @@ -462,7 +464,9 @@ class QuickPayRepoTest : BaseUnitTest() { val (bolt11, _) = testInvoice() val started = CompletableDeferred() val hold = CompletableDeferred>() - whenever { lightningRepo.payInvoice(any(), anyOrNull(), any()) }.doSuspendableAnswer { + 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() } @@ -512,6 +516,79 @@ class QuickPayRepoTest : BaseUnitTest() { 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() @@ -559,8 +636,11 @@ class QuickPayRepoTest : BaseUnitTest() { } private suspend fun stubPayInvoiceFailure(error: NodeException) { - whenever { lightningRepo.payInvoice(any(), anyOrNull(), any()) } - .thenReturn(Result.failure(LdkError(error))) + 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( From 18b30b588a19282bde4518fd1a1a88fd6eb97403 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sun, 23 Aug 2026 19:28:23 +0200 Subject: [PATCH 47/71] fix: replay live quickpay pending session --- .../to/bitkit/repositories/QuickPayRepo.kt | 21 ++++++- .../java/to/bitkit/viewmodels/AppViewModel.kt | 4 +- .../bitkit/repositories/QuickPayRepoTest.kt | 62 +++++++++++++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 16 +++++ 4 files changed, 101 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt index 39c8b0d36..d0fbc67c6 100644 --- a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt @@ -233,7 +233,7 @@ class QuickPayRepo @Inject constructor( val invoice = resolveInvoice(session, request) ?: return val invoiceHash = invoiceHashOrEmit(session, invoice) ?: return when (preparePay(session, invoice, invoiceHash)) { - PreparePayResult.LIVE -> return + PreparePayResult.LIVE -> replayLive(invoiceHash) PreparePayResult.REJECTED -> return PreparePayResult.RECOVERED -> settleRecovered(invoiceHash) PreparePayResult.FRESH -> { @@ -243,6 +243,25 @@ class QuickPayRepo @Inject constructor( } } + 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 diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 7412f3b6f..1b44ce256 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -2689,7 +2689,9 @@ class AppViewModel @Inject constructor( lnurlPay: LnurlPayData? = null, invoice: LightningInvoice? = null, ): Boolean { - if (!canApplyQuickPay(amountSats)) return false + val invoiceHash = invoice?.paymentHash?.toHex()?.takeIf { it.isNotBlank() } + val open = invoiceHash != null && quickPayRepo.hasOpen(invoiceHash) + if (!open && !canApplyQuickPay(amountSats)) return false Logger.info("Using QuickPay for '$amountSats' sats", context = TAG) diff --git a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt index d7c837a62..6871ea096 100644 --- a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt @@ -459,6 +459,68 @@ class QuickPayRepoTest : BaseUnitTest() { 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() diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 507aeef4c..e26b04348 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -229,6 +229,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { 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) @@ -2459,6 +2460,21 @@ class AppViewModelSendFlowTest : BaseUnitTest() { 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" From 62e75149a40bd4d449ed803ae0cccfc947c095d9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sun, 23 Aug 2026 19:29:43 +0200 Subject: [PATCH 48/71] fix: resolve pending on null payment hash --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 5 +-- .../viewmodels/AppViewModelSendFlowTest.kt | 31 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 1b44ce256..2497e09f0 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1159,13 +1159,14 @@ class AppViewModel @Inject constructor( } private suspend fun handlePaymentFailed(event: Event.PaymentFailed) { - quickPayRepo.signalCompletion( + val outcome = quickPayRepo.signalCompletion( paymentId = event.paymentId, paymentHash = event.paymentHash, success = false, failureReason = event.reason, ) - event.paymentHash?.let { paymentHash -> + val paymentHash = event.paymentHash ?: outcome.invoicePaymentHash ?: event.paymentId + if (paymentHash != null) { activityRepo.handlePaymentEvent(paymentHash) if (pendingPaymentRepo.isPending(paymentHash)) { clearPendingContactPaymentContext(paymentHash) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index e26b04348..5c9993073 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -1770,6 +1770,37 @@ class AppViewModelSendFlowTest : BaseUnitTest() { 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" From 163dc94269c7ac4396f226a51ca382711c827ea2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sun, 23 Aug 2026 19:31:40 +0200 Subject: [PATCH 49/71] fix: keep live records across day prune --- .../to/bitkit/repositories/QuickPayRepo.kt | 31 ++++++++++---- .../bitkit/repositories/QuickPayRepoTest.kt | 42 +++++++++++++++++++ 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt index d0fbc67c6..4cc1a29c3 100644 --- a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt @@ -202,7 +202,14 @@ class QuickPayRepo @Inject constructor( runSuspendCatching { if (paymentHash.isBlank()) return@runSuspendCatching null val prepared = prepareReserve(amountSats) ?: return@runSuspendCatching null - mutex.withLock { spend.reserve(paymentHash, prepared.amountCents, prepared.capCents) } + mutex.withLock { + spend.reserve( + paymentHash, + prepared.amountCents, + prepared.capCents, + opsByKey.values.map { it.invoiceHash }.toSet(), + ) + } } } @@ -295,7 +302,10 @@ class QuickPayRepo @Inject constructor( registerOp(recoveredOp(session, invoice, invoiceHash, open)) return@withLock PreparePayResult.RECOVERED } - if (prepared == null || spend.reserve(invoiceHash, prepared.amountCents, prepared.capCents) == null) { + 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 } @@ -733,7 +743,8 @@ class QuickPayRepo @Inject constructor( return PreparedReserve(amountCents, capCents) } - private suspend fun loadPaymentRows(): List? = paymentLookup.rows() + private suspend fun loadPaymentRows(): List? = + runSuspendCatching { paymentLookup.rows() }.getOrNull() private fun emitToSession(sessionId: String?, event: QuickPaySessionEvent) { if (sessionId == null) return @@ -796,6 +807,7 @@ internal class QuickPaySpendStore( paymentHash: String, amountCents: Long, capCents: Long, + keepHashes: Set = emptySet(), ): QuickPayLedgerRecord? { var reserved: QuickPayLedgerRecord? = null val wrote = writeLedger { ledger, dayKey -> @@ -804,7 +816,7 @@ internal class QuickPaySpendStore( 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) + val next = ledger.pruned(spend.dayKey, keepHashes) val record = QuickPayLedgerRecord( id = UUID.randomUUID().toString(), amountCents = amountCents, @@ -871,7 +883,7 @@ internal class QuickPaySpendStore( ) { if (rows == null) return writeLedger { ledger, dayKey -> - val next = ledger.pruned(dayKey) + val next = ledger.pruned(dayKey, liveSubmittingHashes) val remaining = mutableListOf() var spent = next.spentCents for (record in next.records) { @@ -1032,9 +1044,14 @@ 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): QuickPayLedger { +private fun QuickPayLedger.pruned( + currentDay: String, + keepHashes: Set = emptySet(), +): QuickPayLedger { if (currentDay.isEmpty()) return this - return copy(records = records.filter { it.dayKey >= currentDay }) + return copy( + records = records.filter { it.dayKey >= currentDay || it.invoicePaymentHash in keepHashes }, + ) } private fun spendFor(ledger: QuickPayLedger, dayKey: String): QuickPayDaySpend = when { diff --git a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt index 6871ea096..d229fd2cc 100644 --- a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt @@ -345,6 +345,48 @@ class QuickPayRepoTest : BaseUnitTest() { 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()) From 361228ed34833fca2dab5e651cbc0f2e9289b82f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sun, 23 Aug 2026 19:31:52 +0200 Subject: [PATCH 50/71] fix: drop pending resolution flow buffer --- .../java/to/bitkit/repositories/PendingPaymentRepo.kt | 2 +- .../to/bitkit/repositories/PendingPaymentRepoTest.kt | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/PendingPaymentRepo.kt b/app/src/main/java/to/bitkit/repositories/PendingPaymentRepo.kt index 3477e31ee..90b9cb165 100644 --- a/app/src/main/java/to/bitkit/repositories/PendingPaymentRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PendingPaymentRepo.kt @@ -19,7 +19,7 @@ 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()) diff --git a/app/src/test/java/to/bitkit/repositories/PendingPaymentRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PendingPaymentRepoTest.kt index a93670911..ee7007a76 100644 --- a/app/src/test/java/to/bitkit/repositories/PendingPaymentRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PendingPaymentRepoTest.kt @@ -97,6 +97,16 @@ 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)) From 270f22db082fc29aaaa165e51dd5de42e2c41958 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sun, 23 Aug 2026 20:38:11 +0200 Subject: [PATCH 51/71] fix: replay quickpay pending under prepare lock --- .../to/bitkit/repositories/QuickPayRepo.kt | 28 +++++++--------- .../bitkit/repositories/QuickPayRepoTest.kt | 32 +++++++++++++++++++ 2 files changed, 44 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt index 4cc1a29c3..294edaa14 100644 --- a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt @@ -240,7 +240,7 @@ class QuickPayRepo @Inject constructor( val invoice = resolveInvoice(session, request) ?: return val invoiceHash = invoiceHashOrEmit(session, invoice) ?: return when (preparePay(session, invoice, invoiceHash)) { - PreparePayResult.LIVE -> replayLive(invoiceHash) + PreparePayResult.LIVE -> return PreparePayResult.REJECTED -> return PreparePayResult.RECOVERED -> settleRecovered(invoiceHash) PreparePayResult.FRESH -> { @@ -254,21 +254,6 @@ class QuickPayRepo @Inject constructor( 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 @@ -295,6 +280,17 @@ class QuickPayRepo @Inject constructor( val existing = opsByKey[invoiceHash] if (existing != null) { existing.sessionId = session.id + when { + existing.emitted -> emitToSession( + session.id, + QuickPaySessionEvent.Pending( + paymentHash = existing.invoiceHash, + amount = existing.displaySats.toLong(), + paymentRequest = existing.paymentRequest, + ), + ) + existing.job?.isActive != true -> emitPendingLocked(existing) + } return@withLock PreparePayResult.LIVE } val open = spend.matching(invoiceHash) diff --git a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt index d229fd2cc..281223558 100644 --- a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt @@ -11,6 +11,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.withContext import org.junit.After import org.junit.Before @@ -546,6 +548,36 @@ class QuickPayRepoTest : BaseUnitTest() { assertTrue(cacheStore.data.first().quickPayLedger!!.records.isEmpty()) } + @Test + fun `zombie rescan replays pending after detach timeout`() = 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 first = QuickPaySession() + sut.attach(first) + backgroundScope.launch { + sut.payNow(first, QuickPayPayRequest.Bolt11(bolt11 = bolt11, amountSats = 500u)) + } + dispatched.await() + sut.detach(first) + advanceTimeBy(LightningRepo.SEND_LN_TIMEOUT) + advanceUntilIdle() + val second = QuickPaySession() + sut.attach(second).test { + sut.payNow(second, QuickPayPayRequest.Bolt11(bolt11 = bolt11, amountSats = 500u)) + assertIs(awaitItem()) + } + verify(lightningRepo, times(1)).payInvoice(any(), anyOrNull(), any()) + verify(pendingPaymentRepo).track(hash) + assertEquals(250L, spentCents()) + assertEquals(1, cacheStore.data.first().quickPayLedger!!.records.size) + } + @Test fun `hasOpen is true for a live op or recovered row`() = test { val (bolt11, hash) = testInvoice() From a295e31f4d6625eeae9ef869edc8bf4c5e0856cf Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sun, 23 Aug 2026 20:40:02 +0200 Subject: [PATCH 52/71] fix: snapshot sessions before detach all --- .../java/to/bitkit/repositories/QuickPayRepo.kt | 2 +- .../java/to/bitkit/repositories/QuickPayRepoTest.kt | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt index 294edaa14..71bb16d71 100644 --- a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt @@ -158,8 +158,8 @@ class QuickPayRepo @Inject constructor( } fun detachAll() { + val ids = sessionFlows.keys.toList() scope.launch { - val ids = sessionFlows.keys.toList() ids.forEach { detachSession(it) } } } diff --git a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt index 281223558..6db3b0ea4 100644 --- a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt @@ -578,6 +578,19 @@ class QuickPayRepoTest : BaseUnitTest() { assertEquals(1, cacheStore.data.first().quickPayLedger!!.records.size) } + @Test + fun `detachAll does not sweep a session attached after snapshot`() = test { + val first = QuickPaySession() + sut.attach(first) + sut.detachAll() + stubPayInvoiceFailure(NodeException.InvalidInvoice("bad")) + val second = QuickPaySession() + sut.attach(second).test { + sut.payNow(second, QuickPayPayRequest.Bolt11(bolt11 = testInvoice().first, amountSats = 500u)) + assertIs(awaitItem()) + } + } + @Test fun `hasOpen is true for a live op or recovered row`() = test { val (bolt11, hash) = testInvoice() From aaef29026c8d40c0eaa07e82e4c362b667ce81a3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sun, 23 Aug 2026 20:40:02 +0200 Subject: [PATCH 53/71] fix: keep contact gate over open hash routing --- .../main/java/to/bitkit/viewmodels/AppViewModel.kt | 1 + .../bitkit/viewmodels/AppViewModelSendFlowTest.kt | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 2497e09f0..c3009c414 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -2690,6 +2690,7 @@ 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 diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 5c9993073..73a905cfb 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -2812,6 +2812,20 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) } + @Test + fun `contact lightning payment skips QuickPay even when hash is open`() = test { + val bolt11 = "lnbcrt1contactopen" + enableQuickPay() + whenever { quickPayRepo.hasOpen(any()) }.thenReturn(true) + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + + sut.openContactPayment(paymentRequest = bolt11, publicKey = "pubkycontact") + advanceUntilIdle() + + assertNull(sut.quickPayData.value) + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + } + @Test fun `incoming payment request opens the existing confirm flow with its fixed amount`() = test { val request = paymentRequest() From d83ea5695e2acac9ae66125ccc3155c8fc2a172a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sun, 23 Aug 2026 20:40:02 +0200 Subject: [PATCH 54/71] fix: guard quickpay pay re-entry --- .../to/bitkit/viewmodels/QuickPayViewModel.kt | 5 ++- .../viewmodels/QuickPayViewModelTest.kt | 35 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index c15d88e13..184d6fc22 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -35,9 +35,11 @@ class QuickPayViewModel @Inject constructor( val lightningState = lightningRepo.lightningState private var session: QuickPaySession? = null private var resultJob: Job? = null + private var isPayRequested = false fun attach(session: QuickPaySession) { this.session = session + isPayRequested = false resultJob?.cancel() resultJob = viewModelScope.launch { quickPayRepo.attach(session).collect { event -> @@ -54,7 +56,8 @@ class QuickPayViewModel @Inject constructor( } fun pay(session: QuickPaySession, data: QuickPayData) { - if (_uiState.value.result != null) return + if (isPayRequested || _uiState.value.result != null) return + isPayRequested = true quickPayRepo.pay(session, data.toPayRequest()) } diff --git a/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt index 95c447ab2..53d7a653b 100644 --- a/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt @@ -112,6 +112,41 @@ class QuickPayViewModelTest : BaseUnitTest() { verify(quickPayRepo, never()).pay(any(), any()) } + @Test + fun `pay ignores a second call before a result`() = test { + val session = QuickPaySession() + sut.attach(session) + val data = QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test") + + sut.pay(session, data) + sut.pay(session, data) + + verify(quickPayRepo, times(1)).pay( + session, + QuickPayPayRequest.Bolt11(bolt11 = "lnbcrt1test", amountSats = 500u), + ) + } + + @Test + fun `attach resets pay re-entry guard`() = test { + val first = QuickPaySession() + val second = QuickPaySession() + val data = QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test") + sut.attach(first) + sut.pay(first, data) + sut.attach(second) + sut.pay(second, data) + + verify(quickPayRepo, times(1)).pay( + first, + QuickPayPayRequest.Bolt11(bolt11 = "lnbcrt1test", amountSats = 500u), + ) + verify(quickPayRepo, times(1)).pay( + second, + QuickPayPayRequest.Bolt11(bolt11 = "lnbcrt1test", amountSats = 500u), + ) + } + @Test fun `conversion failure uses currency conversion message`() = test { val session = QuickPaySession() From b0f7791e4f52daf5c505ad433dc4c3d231fdde3e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sun, 23 Aug 2026 20:40:36 +0200 Subject: [PATCH 55/71] refactor: rename locked quickpay helpers --- .../java/to/bitkit/repositories/QuickPayRepo.kt | 14 +++++++------- .../bitkit/viewmodels/AppViewModelSendFlowTest.kt | 14 ++++++++------ 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt index 71bb16d71..99c601da8 100644 --- a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt @@ -295,21 +295,21 @@ class QuickPayRepo @Inject constructor( } val open = spend.matching(invoiceHash) if (open != null) { - registerOp(recoveredOp(session, invoice, invoiceHash, open)) + registerOpLocked(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) + rejectCapLocked(session, invoice) return@withLock PreparePayResult.REJECTED } if (sessionFlows[session.id] == null) { spend.release(invoiceHash) return@withLock PreparePayResult.REJECTED } - registerOp( + registerOpLocked( InFlightOp( invoiceHash = invoiceHash, displaySats = invoice.amountSats, @@ -458,7 +458,7 @@ class QuickPayRepo @Inject constructor( paymentHash = invoiceHash, success = false, ) - emitOutcome(invoiceHash, error, paymentRequest) + emitOutcomeLocked(invoiceHash, error, paymentRequest) } } QuickPayDispatchClass.DUPLICATE_PAYMENT, @@ -652,7 +652,7 @@ class QuickPayRepo @Inject constructor( paymentId = open.paymentId, ) - private fun rejectCap(session: QuickPaySession, invoice: ResolvedInvoice) { + private fun rejectCapLocked(session: QuickPaySession, invoice: ResolvedInvoice) { Logger.info( "Skipping QuickPay pay: daily spend reserve failed for '${invoice.amountSats}'", context = TAG, @@ -660,7 +660,7 @@ class QuickPayRepo @Inject constructor( emitToSession(session.id, QuickPaySessionEvent.FallBackToConfirm) } - private fun registerOp(op: InFlightOp) { + private fun registerOpLocked(op: InFlightOp) { opsByKey[op.invoiceHash] = op op.paymentId?.takeIf { it.isNotBlank() && it != op.invoiceHash }?.let { opsByKey[it] = op } } @@ -669,7 +669,7 @@ class QuickPayRepo @Inject constructor( opsByKey.entries.removeAll { it.value === op } } - private fun emitOutcome( + private fun emitOutcomeLocked( invoiceHash: String, error: Throwable, paymentRequest: String, diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 73a905cfb..3c8e740f9 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -97,6 +97,8 @@ import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitPaymentResult import to.bitkit.repositories.PublicPaykitRepo +import to.bitkit.repositories.QuickPayCompletionKind +import to.bitkit.repositories.QuickPayCompletionOutcome import to.bitkit.repositories.QuickPayRepo import to.bitkit.repositories.SamRockRepo import to.bitkit.repositories.SettledReceiveAddress @@ -232,7 +234,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever { quickPayRepo.hasOpen(any()) }.thenReturn(false) whenever { quickPayRepo.signalCompletion(anyOrNull(), anyOrNull(), any(), anyOrNull(), anyOrNull()) - }.thenReturn(to.bitkit.repositories.QuickPayCompletionOutcome.None) + }.thenReturn(QuickPayCompletionOutcome.None) whenever { activityRepo.findActivityByPaymentId(any(), any(), any(), any()) } .thenReturn(Result.failure(Exception("activity not found"))) whenever(transferRepo.activeTransfers).thenReturn(flowOf(emptyList())) @@ -1832,8 +1834,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever { quickPayRepo.signalCompletion(anyOrNull(), anyOrNull(), any(), anyOrNull(), anyOrNull()) }.thenReturn( - to.bitkit.repositories.QuickPayCompletionOutcome( - kind = to.bitkit.repositories.QuickPayCompletionKind.SETTLED_SUCCESS, + QuickPayCompletionOutcome( + kind = QuickPayCompletionKind.SETTLED_SUCCESS, invoicePaymentHash = paymentHash, ), ) @@ -1865,7 +1867,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(pendingPaymentRepo.isActive(paymentHash)).thenReturn(false) whenever { quickPayRepo.signalCompletion(anyOrNull(), anyOrNull(), any(), anyOrNull(), anyOrNull()) - }.thenReturn(to.bitkit.repositories.QuickPayCompletionOutcome.None) + }.thenReturn(QuickPayCompletionOutcome.None) advanceUntilIdle() emitNodeEvent( @@ -1895,8 +1897,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever { quickPayRepo.signalCompletion(anyOrNull(), anyOrNull(), any(), anyOrNull(), anyOrNull()) }.thenReturn( - to.bitkit.repositories.QuickPayCompletionOutcome( - kind = to.bitkit.repositories.QuickPayCompletionKind.SETTLED_SUCCESS, + QuickPayCompletionOutcome( + kind = QuickPayCompletionKind.SETTLED_SUCCESS, invoicePaymentHash = paymentHash, ), ) From 660f79c786cd60086b97cbe8d22ecd232fd6438b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sun, 23 Aug 2026 20:44:46 +0200 Subject: [PATCH 56/71] chore: suppress largeclass on quickpay test --- app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt index 6db3b0ea4..818ed3d77 100644 --- a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt @@ -52,6 +52,7 @@ import kotlin.time.Instant @OptIn(ExperimentalCoroutinesApi::class) @Config(application = Application::class, sdk = [34]) @RunWith(RobolectricTestRunner::class) +@Suppress("LargeClass") class QuickPayRepoTest : BaseUnitTest() { companion object { private const val TEST_BOLT11 = "lnbcrt1quickpay" From 145f2d5a5f1b2a8d3974556dc2a0f747ebf0f3bb Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sun, 23 Aug 2026 20:47:19 +0200 Subject: [PATCH 57/71] refactor: import instead of fqn --- app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt | 3 ++- .../test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt index 99c601da8..7f6163108 100644 --- a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt @@ -1,5 +1,6 @@ package to.bitkit.repositories +import com.synonym.bitkitcore.LnurlPayData import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Job @@ -63,7 +64,7 @@ sealed interface QuickPayPayRequest { ) : QuickPayPayRequest data class LnurlPay( - val data: com.synonym.bitkitcore.LnurlPayData, + val data: LnurlPayData, override val amountSats: ULong, ) : QuickPayPayRequest } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 3c8e740f9..359c39501 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.Activity as BitkitActivity import com.synonym.bitkitcore.LightningActivity import com.synonym.bitkitcore.LightningInvoice import com.synonym.bitkitcore.NetworkType @@ -1891,7 +1892,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { on { value } doReturn 500u on { fee } doReturn 0u } - val activity = mock { on { v1 } doReturn activityV1 } + val activity = mock { on { v1 } doReturn activityV1 } whenever(pendingPaymentRepo.isPending(paymentHash)).thenReturn(true) whenever(pendingPaymentRepo.isActive(paymentHash)).thenReturn(false) whenever { From 96c42e7085cb417733cb33accfd67c7ce5632fcb Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sun, 23 Aug 2026 21:18:09 +0200 Subject: [PATCH 58/71] chore: put activity alias last in imports --- .../test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 359c39501..305384ddd 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -9,7 +9,6 @@ import android.net.Uri import android.nfc.NfcAdapter import androidx.core.net.toUri import app.cash.turbine.test -import com.synonym.bitkitcore.Activity as BitkitActivity import com.synonym.bitkitcore.LightningActivity import com.synonym.bitkitcore.LightningInvoice import com.synonym.bitkitcore.NetworkType @@ -136,6 +135,7 @@ import kotlin.test.assertTrue import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime +import com.synonym.bitkitcore.Activity as BitkitActivity @OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) @RunWith(RobolectricTestRunner::class) From ce9d22ae450466b7b55abfb56875b3fa9b4390d5 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sun, 23 Aug 2026 23:46:21 +0200 Subject: [PATCH 59/71] fix: keep quickpay data during fallback exit --- app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 15753d15a..12185bc5d 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -326,8 +326,10 @@ fun SendSheet( } composableWithDefaultTransitions { val quickPayData by appViewModel.quickPayData.collectAsStateWithLifecycle() + val displayedQuickPayData = remember { quickPayData } ?: quickPayData + if (displayedQuickPayData == null) return@composableWithDefaultTransitions SendQuickPayScreen( - quickPayData = requireNotNull(quickPayData), + quickPayData = displayedQuickPayData, onPaymentComplete = { paymentHash, amountWithFee -> appViewModel.onSendSuccess( NewTransactionSheetDetails( From eb7eed9dc44925337a38c013ed79b7436a2eb213 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 24 Aug 2026 18:20:14 +0200 Subject: [PATCH 60/71] fix: update daily cap slider copy --- .../to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt | 3 +-- app/src/main/res/values/strings.xml | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) 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 ea30a78d9..38f4bb738 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 @@ -124,8 +124,7 @@ fun QuickPaySettingsScreenContent( BodyM( text = stringResource(R.string.settings__quickpay__settings__daily_text) - .replace("{limit}", dailyLimitUsd.toString()) - .replace("{multiplier}", quickPayDailyLimitMultiplier.toString()), + .replace("{limit}", dailyLimitUsd.toString()), color = Colors.White64, ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 57a288533..b86dda5cd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -934,7 +934,7 @@ <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. + Pay up to ${limit} per day without entering your PIN. After that, confirm each payment. Quickpay threshold {multiplier}× * Bitkit QuickPay exclusively supports payments from your Spending Balance. From 6de4e7934bb295bf0469d2021e3ed7ba7f92a017 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 24 Aug 2026 18:21:27 +0200 Subject: [PATCH 61/71] fix: drop quickpay settings illustration --- .../ui/settings/quickPay/QuickPaySettingsScreen.kt | 12 ------------ 1 file changed, 12 deletions(-) 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 38f4bb738..c44f11972 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 @@ -1,9 +1,6 @@ package to.bitkit.ui.settings.quickPay -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Column -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 @@ -12,7 +9,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -138,14 +134,6 @@ fun QuickPaySettingsScreenContent( modifier = Modifier.testTag("QuickpayDailyLimitSlider") ) - VerticalSpacer(32.dp) - Image( - painter = painterResource(R.drawable.fast_forward), - contentDescription = null, - modifier = Modifier - .fillMaxWidth() - .height(256.dp) - ) VerticalSpacer(32.dp) BodyS( From df19bfd7024bb720f5126d59e0a9a35946225442 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 24 Aug 2026 23:21:34 +0200 Subject: [PATCH 62/71] fix: skip toast on quickpay error --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 2 +- .../viewmodels/AppViewModelSendFlowTest.kt | 32 ++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index c3009c414..fb47f92d3 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1187,7 +1187,7 @@ class AppViewModel @Inject constructor( } private fun closeActiveSendForFailedPayment(paymentHash: String, reason: PaymentFailureReason?): Boolean { - if (_quickPayData.value != null) return false + if (_quickPayData.value != null && _currentSheet.value is Sheet.Send) return true val activePaymentHash = _sendUiState.value.decodedInvoice?.paymentHash?.toHex() if (_currentSheet.value !is Sheet.Send || activePaymentHash != paymentHash) return false diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 305384ddd..60834508b 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -1975,12 +1975,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `in-flight QuickPay failure does not navigate to confirm error`() = test { + fun `in-flight QuickPay failure does not toast or navigate to confirm error`() = test { val bolt11 = "lnbcrt1quickpayfail" enableQuickPay() stubLightningScan(bolt11 = bolt11, amountSats = 500u) sut.onScanResult(bolt11) advanceUntilIdle() + clearInvocations(toastManager) sut.sendEffect.test { emitNodeEvent( @@ -1993,6 +1994,35 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() expectNoEvents() } + verify(toastManager, never()).enqueue(any()) + } + + @Test + fun `QuickPay failure still toasts after send sheet is hidden`() = test { + val bolt11 = "lnbcrt1quickpayhiddenfail" + whenever(context.getString(R.string.wallet__toast_payment_failed_title)).thenReturn("Payment failed") + whenever(context.getString(R.string.wallet__payment_route_not_found)).thenReturn("no route") + enableQuickPay() + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + sut.onScanResult(bolt11) + advanceUntilIdle() + sut.hideSheet() + clearInvocations(toastManager) + + emitNodeEvent( + Event.PaymentFailed( + paymentId = "payment_id", + paymentHash = "010203", + reason = PaymentFailureReason.ROUTE_NOT_FOUND, + ), + ) + advanceUntilIdle() + + verify(toastManager).enqueue( + check { + assertEquals("PaymentFailedToast", it.testTag) + } + ) } @Test From 6f695eeee064a1a5e9b328f02698c07338e68ed9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 24 Aug 2026 23:33:58 +0200 Subject: [PATCH 63/71] fix: harden quickpay success and dispatch --- .../java/to/bitkit/repositories/QuickPayRepo.kt | 6 ++++++ .../java/to/bitkit/viewmodels/AppViewModel.kt | 2 +- .../to/bitkit/repositories/QuickPayRepoTest.kt | 15 +++++++++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 17 ++++++++++------- 4 files changed, 32 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt index 7f6163108..060364291 100644 --- a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt @@ -495,6 +495,12 @@ class QuickPayRepo @Inject constructor( val remaining = spend.matching(invoiceHash) val op = opsByKey[invoiceHash] if (remaining != null) { + if (!duplicate && op != null && !op.dispatched) { + spend.release(invoiceHash) + emitErrorLocked(op, error, paymentRequest) + removeOpLocked(op) + return + } op?.dispatched = true op?.let { emitPendingLocked(it) } return diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index fb47f92d3..b0ed06ab3 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1249,13 +1249,13 @@ class AppViewModel @Inject constructor( private suspend fun handlePaymentSuccessful(event: Event.PaymentSuccessful) { val paymentHash = event.paymentHash - activityRepo.handlePaymentEvent(paymentHash) val isQuickPay = quickPayRepo.signalCompletion( paymentId = event.paymentId, paymentHash = paymentHash, success = true, feePaidMsat = event.feePaidMsat, ).wasQuickPay + activityRepo.handlePaymentEvent(paymentHash) if (!pendingPaymentRepo.isPending(paymentHash)) { notifyPaymentSentOnLightning(event) return diff --git a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt index 818ed3d77..146d68e68 100644 --- a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt @@ -37,6 +37,7 @@ import to.bitkit.di.json import to.bitkit.models.ConvertedAmount import to.bitkit.models.USD import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError import to.bitkit.utils.LdkError import java.math.BigDecimal import java.util.Locale @@ -713,6 +714,20 @@ class QuickPayRepoTest : BaseUnitTest() { verify(lightningRepo, times(1)).payInvoice(any(), anyOrNull(), any()) } + @Test + fun `ambiguous failure before dispatch refunds and errors`() = test { + val (bolt11, _) = testInvoice() + whenever(lightningRepo.payInvoice(any(), anyOrNull(), any())) + .thenReturn(Result.failure(AppError("Cannot execute 'payInvoice'"))) + 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 `pre-dispatch rejection refunds after dispatch`() = test { val (bolt11, _) = testInvoice() diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 60834508b..09a844d96 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -1851,13 +1851,16 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) advanceUntilIdle() - verify(quickPayRepo).signalCompletion( - paymentId = "payment_id", - paymentHash = paymentHash, - success = true, - feePaidMsat = 10uL, - failureReason = null, - ) + inOrder(quickPayRepo, activityRepo) { + verify(quickPayRepo).signalCompletion( + paymentId = "payment_id", + paymentHash = paymentHash, + success = true, + feePaidMsat = 10uL, + failureReason = null, + ) + verify(activityRepo).handlePaymentEvent(paymentHash) + } verify(pendingPaymentRepo, never()).resolve(any()) } From e5f74f4bf61afb9a8c6a221d4fec6c147a740039 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 24 Aug 2026 23:49:54 +0200 Subject: [PATCH 64/71] fix: toast pending after sheet replace --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 2 +- .../viewmodels/AppViewModelSendFlowTest.kt | 57 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index b0ed06ab3..09e80f9a6 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1182,7 +1182,7 @@ class AppViewModel @Inject constructor( } private fun shouldNotifyPendingResolution(paymentHash: String): Boolean { - if (_quickPayData.value != null) return false + if (_quickPayData.value != null) return _currentSheet.value !is Sheet.Send return _currentSheet.value !is Sheet.Send || !pendingPaymentRepo.isActive(paymentHash) } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 09a844d96..4b40cf186 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -2028,6 +2028,63 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) } + @Test + fun `pending QuickPay failure does not toast while send sheet is open`() = test { + val bolt11 = "lnbcrt1quickpaypendingopen" + val paymentHash = "010203" + whenever(pendingPaymentRepo.isPending(paymentHash)).thenReturn(true) + whenever(pendingPaymentRepo.isActive(paymentHash)).thenReturn(true) + enableQuickPay() + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + sut.onScanResult(bolt11) + advanceUntilIdle() + clearInvocations(toastManager) + + emitNodeEvent( + Event.PaymentFailed( + paymentId = "payment_id", + paymentHash = paymentHash, + reason = PaymentFailureReason.ROUTE_NOT_FOUND, + ), + ) + advanceUntilIdle() + + verify(toastManager, never()).enqueue(any()) + } + + @Test + fun `pending QuickPay failure toasts after send sheet is replaced`() = test { + val bolt11 = "lnbcrt1quickpaypendingreplaced" + val paymentHash = "010203" + whenever(context.getString(R.string.wallet__toast_payment_failed_title)).thenReturn("Payment failed") + whenever(context.getString(R.string.wallet__toast_payment_failed_description)).thenReturn("failed") + whenever(pendingPaymentRepo.isPending(paymentHash)).thenReturn(true) + whenever(pendingPaymentRepo.isActive(paymentHash)).thenReturn(false) + enableQuickPay() + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + sut.onScanResult(bolt11) + advanceUntilIdle() + sut.showSheet(Sheet.ConnectionClosed) + advanceTimeBy(TRANSITION_SCREEN_MS) + advanceUntilIdle() + clearInvocations(toastManager) + + emitNodeEvent( + Event.PaymentFailed( + paymentId = "payment_id", + paymentHash = paymentHash, + reason = PaymentFailureReason.ROUTE_NOT_FOUND, + ), + ) + advanceUntilIdle() + + verify(toastManager).enqueue( + check { + assertEquals("PendingPaymentFailedToast", it.testTag) + } + ) + } + @Test fun `confirm failure still navigates after QuickPay fallback`() = test { val bolt11 = "lnbcrt1quickpayfallback" From cd655f6713f74683ded993e1c63bc2b4d782861a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 25 Aug 2026 10:09:23 +0200 Subject: [PATCH 65/71] fix: attribute toasts and settle pending --- .../to/bitkit/repositories/QuickPayRepo.kt | 14 ++-- .../java/to/bitkit/viewmodels/AppViewModel.kt | 20 ++++-- .../bitkit/repositories/QuickPayRepoTest.kt | 15 ++++ .../viewmodels/AppViewModelSendFlowTest.kt | 71 +++++++++++++++++++ 4 files changed, 109 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt index 060364291..5216757f2 100644 --- a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt @@ -486,6 +486,13 @@ class QuickPayRepo @Inject constructor( rows: List?, duplicate: Boolean, ) { + val op = opsByKey[invoiceHash] + if (!duplicate && op != null && !op.dispatched) { + spend.release(invoiceHash) + emitErrorLocked(op, error, paymentRequest) + removeOpLocked(op) + return + } val record = spend.matching(invoiceHash) val applied = if (record != null && rows != null) { applyAmbiguousLookupLocked(record, rows, duplicate) @@ -493,14 +500,7 @@ class QuickPayRepo @Inject constructor( AmbiguousApply.UNCHANGED } val remaining = spend.matching(invoiceHash) - val op = opsByKey[invoiceHash] if (remaining != null) { - if (!duplicate && op != null && !op.dispatched) { - spend.release(invoiceHash) - emitErrorLocked(op, error, paymentRequest) - removeOpLocked(op) - return - } op?.dispatched = true op?.let { emitPendingLocked(it) } return diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 09e80f9a6..ea4aebb1d 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1167,7 +1167,7 @@ class AppViewModel @Inject constructor( ) val paymentHash = event.paymentHash ?: outcome.invoicePaymentHash ?: event.paymentId if (paymentHash != null) { - activityRepo.handlePaymentEvent(paymentHash) + refreshPaymentActivity(paymentHash) if (pendingPaymentRepo.isPending(paymentHash)) { clearPendingContactPaymentContext(paymentHash) pendingPaymentRepo.resolve(PendingPaymentResolution.Failure(paymentHash, event.reason)) @@ -1182,12 +1182,13 @@ class AppViewModel @Inject constructor( } private fun shouldNotifyPendingResolution(paymentHash: String): Boolean { - if (_quickPayData.value != null) return _currentSheet.value !is Sheet.Send + if (isQuickPayHandling(paymentHash)) return false + if (_quickPayData.value != null && _currentSheet.value !is Sheet.Send) return true return _currentSheet.value !is Sheet.Send || !pendingPaymentRepo.isActive(paymentHash) } private fun closeActiveSendForFailedPayment(paymentHash: String, reason: PaymentFailureReason?): Boolean { - if (_quickPayData.value != null && _currentSheet.value is Sheet.Send) return true + if (isQuickPayHandling(paymentHash)) return true val activePaymentHash = _sendUiState.value.decodedInvoice?.paymentHash?.toHex() if (_currentSheet.value !is Sheet.Send || activePaymentHash != paymentHash) return false @@ -1255,7 +1256,7 @@ class AppViewModel @Inject constructor( success = true, feePaidMsat = event.feePaidMsat, ).wasQuickPay - activityRepo.handlePaymentEvent(paymentHash) + refreshPaymentActivity(paymentHash) if (!pendingPaymentRepo.isPending(paymentHash)) { notifyPaymentSentOnLightning(event) return @@ -1273,6 +1274,17 @@ class AppViewModel @Inject constructor( } } + private fun isQuickPayHandling(paymentHash: String): Boolean { + if (_quickPayData.value == null || _currentSheet.value !is Sheet.Send) return false + return _sendUiState.value.decodedInvoice?.paymentHash?.toHex() == paymentHash + } + + private suspend fun refreshPaymentActivity(paymentHash: String) { + runSuspendCatching { activityRepo.handlePaymentEvent(paymentHash) }.onFailure { + Logger.warn("Failed to refresh payment activity for '$paymentHash'", it, context = TAG) + } + } + private suspend fun quickPaySettledAmountSats( paymentHash: String, isQuickPay: Boolean, diff --git a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt index 146d68e68..5eda4f192 100644 --- a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt @@ -728,6 +728,21 @@ class QuickPayRepoTest : BaseUnitTest() { assertTrue(cacheStore.data.first().quickPayLedger!!.records.isEmpty()) } + @Test + fun `ambiguous failure before dispatch refunds even with succeeded ldk row`() = test { + val (bolt11, hash) = testInvoice() + whenever(lightningRepo.payInvoice(any(), anyOrNull(), any())) + .thenReturn(Result.failure(AppError("Cannot execute 'payInvoice'"))) + paymentRows = listOf(succeededRow(hash)) + 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 `pre-dispatch rejection refunds after dispatch`() = test { val (bolt11, _) = testInvoice() diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 4b40cf186..f246b7aaa 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -1864,6 +1864,50 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(pendingPaymentRepo, never()).resolve(any()) } + @Test + fun `pending success still resolves if activity sync fails`() = test { + val paymentHash = "pending_sync_fail" + whenever(pendingPaymentRepo.isPending(paymentHash)).thenReturn(true) + whenever(pendingPaymentRepo.isActive(paymentHash)).thenReturn(false) + whenever { activityRepo.handlePaymentEvent(paymentHash) }.thenThrow(RuntimeException("core")) + + emitNodeEvent( + Event.PaymentSuccessful( + paymentId = "payment_id", + paymentHash = paymentHash, + paymentPreimage = "preimage", + feePaidMsat = 10uL, + ), + ) + advanceUntilIdle() + + verify(pendingPaymentRepo).resolve(PendingPaymentResolution.Success(paymentHash)) + } + + @Test + fun `pending failure still resolves if activity sync fails`() = test { + val paymentHash = "pending_sync_fail_err" + whenever(pendingPaymentRepo.isPending(paymentHash)).thenReturn(true) + whenever(pendingPaymentRepo.isActive(paymentHash)).thenReturn(false) + whenever { activityRepo.handlePaymentEvent(paymentHash) }.thenThrow(RuntimeException("core")) + + emitNodeEvent( + Event.PaymentFailed( + paymentId = "payment_id", + paymentHash = paymentHash, + reason = PaymentFailureReason.ROUTE_NOT_FOUND, + ), + ) + advanceUntilIdle() + + verify(pendingPaymentRepo).resolve( + PendingPaymentResolution.Failure( + paymentHash = paymentHash, + reason = PaymentFailureReason.ROUTE_NOT_FOUND, + ) + ) + } + @Test fun `pending confirm lightning success keeps invoice amount`() = test { val paymentHash = "pending_confirm_hash" @@ -2000,6 +2044,33 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(toastManager, never()).enqueue(any()) } + @Test + fun `unrelated failure still toasts while QuickPay send is open`() = test { + val bolt11 = "lnbcrt1quickpayunrelated" + whenever(context.getString(R.string.wallet__toast_payment_failed_title)).thenReturn("Payment failed") + whenever(context.getString(R.string.wallet__payment_route_not_found)).thenReturn("no route") + enableQuickPay() + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + sut.onScanResult(bolt11) + advanceUntilIdle() + clearInvocations(toastManager) + + emitNodeEvent( + Event.PaymentFailed( + paymentId = "other_id", + paymentHash = "deadbeef", + reason = PaymentFailureReason.ROUTE_NOT_FOUND, + ), + ) + advanceUntilIdle() + + verify(toastManager).enqueue( + check { + assertEquals("PaymentFailedToast", it.testTag) + } + ) + } + @Test fun `QuickPay failure still toasts after send sheet is hidden`() = test { val bolt11 = "lnbcrt1quickpayhiddenfail" From b207d0eea3a3e08627efec8307383dba1f398ff0 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 25 Aug 2026 16:58:11 +0200 Subject: [PATCH 66/71] fix: attribute quickpay toasts to session --- .../to/bitkit/repositories/QuickPayRepo.kt | 34 ++++++++------- .../java/to/bitkit/viewmodels/AppViewModel.kt | 2 +- .../bitkit/repositories/QuickPayRepoTest.kt | 28 +++++++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 41 +++++++++++++++++++ 4 files changed, 89 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt index 5216757f2..343a21aa1 100644 --- a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt @@ -98,6 +98,7 @@ enum class QuickPayCompletionKind { data class QuickPayCompletionOutcome( val kind: QuickPayCompletionKind = QuickPayCompletionKind.NONE, val invoicePaymentHash: String? = null, + val sessionNotified: Boolean = false, ) { val wasQuickPay: Boolean get() = kind != QuickPayCompletionKind.NONE @@ -557,12 +558,9 @@ class QuickPayRepo @Inject constructor( } else { QuickPayCompletionKind.SETTLED_FAILURE } - val outcome = QuickPayCompletionOutcome( - kind = kind, - invoicePaymentHash = record.invoicePaymentHash, - ) + var sessionNotified = false if (op != null) { - if (success) { + sessionNotified = if (success) { emitSuccessLocked(op, feePaidMsat) } else { emitErrorLocked( @@ -577,7 +575,11 @@ class QuickPayRepo @Inject constructor( } removeOpLocked(op) } - return outcome + return QuickPayCompletionOutcome( + kind = kind, + invoicePaymentHash = record.invoicePaymentHash, + sessionNotified = sessionNotified, + ) } private fun isAttributedFailure( @@ -702,14 +704,14 @@ class QuickPayRepo @Inject constructor( op.settled.complete(Unit) } - private fun emitSuccessLocked(op: InFlightOp, feePaidMsat: ULong?) { + private fun emitSuccessLocked(op: InFlightOp, feePaidMsat: ULong?): Boolean { if (op.emitted) { op.settled.complete(Unit) - return + return false } op.emitted = true val feeSats = msatFloorOf(feePaidMsat ?: 0u) - emitToSession( + val notified = emitToSession( op.sessionId, QuickPaySessionEvent.Success( paymentHash = op.invoiceHash, @@ -717,16 +719,18 @@ class QuickPayRepo @Inject constructor( ), ) op.settled.complete(Unit) + return notified } - private fun emitErrorLocked(op: InFlightOp, error: Throwable, paymentRequest: String?) { + private fun emitErrorLocked(op: InFlightOp, error: Throwable, paymentRequest: String?): Boolean { if (op.emitted) { op.settled.complete(Unit) - return + return false } op.emitted = true - emitToSession(op.sessionId, QuickPaySessionEvent.Error(error, paymentRequest)) + val notified = emitToSession(op.sessionId, QuickPaySessionEvent.Error(error, paymentRequest)) op.settled.complete(Unit) + return notified } private suspend fun prepareReserve(amountSats: ULong): PreparedReserve? { @@ -749,9 +753,9 @@ class QuickPayRepo @Inject constructor( 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 fun emitToSession(sessionId: String?, event: QuickPaySessionEvent): Boolean { + if (sessionId == null) return false + return sessionFlows[sessionId]?.tryEmit(event) == true } private data class InFlightOp( diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index ea4aebb1d..5329c268d 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1176,6 +1176,7 @@ class AppViewModel @Inject constructor( } return } + if (outcome.sessionNotified) return if (closeActiveSendForFailedPayment(paymentHash, event.reason)) return } notifyPaymentFailed(event.reason) @@ -1188,7 +1189,6 @@ class AppViewModel @Inject constructor( } private fun closeActiveSendForFailedPayment(paymentHash: String, reason: PaymentFailureReason?): Boolean { - if (isQuickPayHandling(paymentHash)) return true val activePaymentHash = _sendUiState.value.decodedInvoice?.paymentHash?.toHex() if (_currentSheet.value !is Sheet.Send || activePaymentHash != paymentHash) return false diff --git a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt index 5eda4f192..8b6363d77 100644 --- a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt @@ -187,10 +187,38 @@ class QuickPayRepoTest : BaseUnitTest() { val outcome = sut.signalCompletion(paymentId = "pid", paymentHash = "abc", success = false) assertEquals(QuickPayCompletionKind.SETTLED_FAILURE, outcome.kind) + assertFalse(outcome.sessionNotified) assertEquals(0L, spentCents()) assertTrue(cacheStore.data.first().quickPayLedger!!.records.isEmpty()) } + @Test + fun `signalCompletion notifies an attached session`() = test { + val (bolt11, hash) = 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).test { + backgroundScope.launch { + sut.payNow(session, QuickPayPayRequest.Bolt11(bolt11 = bolt11, amountSats = 500u)) + } + started.await() + + val outcome = sut.signalCompletion(paymentId = null, paymentHash = hash, success = false) + + assertTrue(outcome.sessionNotified) + assertIs(awaitItem()) + } + hold.complete(Result.success("pid")) + } + @Test fun `signalCompletion failure on a prior day does not decrement the new day`() = test { assertNotNull(sut.reserveBound("old", 1000u).getOrThrow()) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index f246b7aaa..41ecc2e37 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -2026,6 +2026,15 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val bolt11 = "lnbcrt1quickpayfail" enableQuickPay() stubLightningScan(bolt11 = bolt11, amountSats = 500u) + whenever { + quickPayRepo.signalCompletion(anyOrNull(), anyOrNull(), any(), anyOrNull(), anyOrNull()) + }.thenReturn( + QuickPayCompletionOutcome( + kind = QuickPayCompletionKind.SETTLED_FAILURE, + invoicePaymentHash = "010203", + sessionNotified = true, + ), + ) sut.onScanResult(bolt11) advanceUntilIdle() clearInvocations(toastManager) @@ -2044,6 +2053,38 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(toastManager, never()).enqueue(any()) } + @Test + fun `LNURL QuickPay failure with notified session does not toast`() = test { + val paymentHash = "lnurlfetchedhash" + whenever(pendingPaymentRepo.isPending(paymentHash)).thenReturn(false) + whenever { + quickPayRepo.signalCompletion(anyOrNull(), anyOrNull(), any(), anyOrNull(), anyOrNull()) + }.thenReturn( + QuickPayCompletionOutcome( + kind = QuickPayCompletionKind.SETTLED_FAILURE, + invoicePaymentHash = paymentHash, + sessionNotified = true, + ), + ) + setSendState(SendUiState(payMethod = SendMethod.LIGHTNING)) + sut.showSheet(Sheet.Send()) + advanceUntilIdle() + clearInvocations(toastManager) + + sut.sendEffect.test { + emitNodeEvent( + Event.PaymentFailed( + paymentId = "payment_id", + paymentHash = paymentHash, + reason = PaymentFailureReason.ROUTE_NOT_FOUND, + ), + ) + advanceUntilIdle() + expectNoEvents() + } + verify(toastManager, never()).enqueue(any()) + } + @Test fun `unrelated failure still toasts while QuickPay send is open`() = test { val bolt11 = "lnbcrt1quickpayunrelated" From 1dde3c3cd59d40533ee39f71ca8dbe2d41d553f6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 25 Aug 2026 21:30:54 +0200 Subject: [PATCH 67/71] refactor: extract quickpay ledger and spend store --- .../main/java/to/bitkit/data/CacheStore.kt | 2 +- .../java/to/bitkit/models/QuickPayLedger.kt | 31 ++ .../to/bitkit/repositories/QuickPayRepo.kt | 386 +++--------------- .../bitkit/repositories/QuickPaySpendStore.kt | 239 +++++++++++ .../bitkit/repositories/QuickPayRepoTest.kt | 2 + 5 files changed, 338 insertions(+), 322 deletions(-) create mode 100644 app/src/main/java/to/bitkit/models/QuickPayLedger.kt create mode 100644 app/src/main/java/to/bitkit/repositories/QuickPaySpendStore.kt diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index da37cc19a..894f588db 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -16,8 +16,8 @@ import to.bitkit.models.BackupItemStatus import to.bitkit.models.BalanceState import to.bitkit.models.FxRate import to.bitkit.models.NewTransactionSheetDetails +import to.bitkit.models.QuickPayLedger import to.bitkit.models.WalletScope -import to.bitkit.repositories.QuickPayLedger import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton diff --git a/app/src/main/java/to/bitkit/models/QuickPayLedger.kt b/app/src/main/java/to/bitkit/models/QuickPayLedger.kt new file mode 100644 index 000000000..d29b30fdc --- /dev/null +++ b/app/src/main/java/to/bitkit/models/QuickPayLedger.kt @@ -0,0 +1,31 @@ +package to.bitkit.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@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(), +) diff --git a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt index 343a21aa1..48c1d93e5 100644 --- a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt @@ -4,6 +4,7 @@ import com.synonym.bitkitcore.LnurlPayData import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.distinctUntilChanged @@ -14,10 +15,6 @@ 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 @@ -32,6 +29,9 @@ import to.bitkit.di.IoDispatcher import to.bitkit.ext.callbackAmountMsats import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.supportPaymentRequest +import to.bitkit.models.QuickPayLedger +import to.bitkit.models.QuickPayLedgerRecord +import to.bitkit.models.QuickPayRecordPhase import to.bitkit.models.USD import to.bitkit.models.msatFloorOf import to.bitkit.models.safe @@ -42,79 +42,8 @@ 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: 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 sessionNotified: Boolean = false, -) { - 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( @@ -318,7 +247,7 @@ class QuickPayRepo @Inject constructor( paymentRequest = invoice.bolt11, dispatched = false, sessionId = session.id, - job = coroutineContext[Job], + job = currentCoroutineContext()[Job], paymentId = null, ), ) @@ -739,9 +668,7 @@ class QuickPayRepo @Inject constructor( settings.quickPayAmount.toDouble(), USD, ).getOrNull() - if (thresholdSats == null || thresholdSats == 0uL || amountSats > thresholdSats) { - return null - } + if (thresholdSats == null || thresholdSats == 0uL || amountSats > thresholdSats) return null val converted = currencyRepo.convertSatsToFiat(amountSats.toLong(), USD).getOrElse { throw QuickPayConversionError() } @@ -787,157 +714,77 @@ class QuickPayRepo @Inject constructor( 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) - } +fun interface QuickPayInvoiceParser { + fun parse(bolt11: String): String? +} - suspend fun matching(hash: String): QuickPayLedgerRecord? { - val (ledger, supported) = cacheStore.data.first().resolvedLedger() - if (!supported) return null - return ledger.recordMatching(hash) - } +fun interface QuickPayPaymentLookup { + suspend fun rows(): List? +} - 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 - } +data class QuickPaySession(val id: String = UUID.randomUUID().toString()) - suspend fun release(paymentHash: String) { - writeLedger { ledger, _ -> releaseRecord(ledger, paymentHash) } - } +sealed interface QuickPayPayRequest { + val amountSats: ULong - 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) }) - } - } + data class Bolt11( + val bolt11: String, + override val amountSats: ULong, + ) : QuickPayPayRequest - 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, - ) - }, - ) - } - } + data class LnurlPay( + val data: LnurlPayData, + override val amountSats: ULong, + ) : QuickPayPayRequest +} - 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) - } - } +sealed interface QuickPaySessionEvent { + data class Success( + val paymentHash: String, + val amountWithFee: Long, + ) : QuickPaySessionEvent - @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) - } - } + data class Pending( + val paymentHash: String, + val amount: Long, + val paymentRequest: String, + ) : QuickPaySessionEvent - 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 - } + data object FallBackToConfirm : QuickPaySessionEvent + + data class Error( + val error: Throwable, + val paymentRequest: String?, + ) : QuickPaySessionEvent +} - private fun currentDayKey(): String = - clock.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.toString() +enum class QuickPayCompletionKind { + NONE, + SETTLED_SUCCESS, + SETTLED_FAILURE, } +data class QuickPayCompletionOutcome( + val kind: QuickPayCompletionKind = QuickPayCompletionKind.NONE, + val invoicePaymentHash: String? = null, + val sessionNotified: Boolean = false, +) { + 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) + internal data class SpendSnapshot( val spentCents: Long, val supported: Boolean, @@ -986,33 +833,6 @@ data class QuickPayReconcileRow( ) } -@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() @@ -1025,79 +845,3 @@ private fun quickPayReserveCents( 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/repositories/QuickPaySpendStore.kt b/app/src/main/java/to/bitkit/repositories/QuickPaySpendStore.kt new file mode 100644 index 000000000..cadd5b3c3 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/QuickPaySpendStore.kt @@ -0,0 +1,239 @@ +package to.bitkit.repositories + +import kotlinx.coroutines.flow.first +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import to.bitkit.data.AppCacheData +import to.bitkit.data.CacheStore +import to.bitkit.models.QuickPayLedger +import to.bitkit.models.QuickPayLedgerRecord +import to.bitkit.models.QuickPayRecordPhase +import java.util.UUID +import kotlin.time.Clock + +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, _ -> ledger.releaseRecord(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() +} + +private data class QuickPayDaySpend( + val dayKey: String, + val spentCents: Long, +) + +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 QuickPayLedger.recordMatching(hash: String): QuickPayLedgerRecord? = + records.find { it.invoicePaymentHash == hash || it.paymentId == hash || it.id == hash } + +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 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.releaseRecord(paymentHash: String): QuickPayLedger { + val index = recordIndex(paymentHash) ?: return this + val record = records[index] + val remaining = records.toMutableList().also { it.removeAt(index) } + val spent = if (record.dayKey == dayKey) { + (spentCents - record.amountCents).coerceAtLeast(0L) + } else { + spentCents + } + return copy(records = remaining, spentCents = spent) +} + +internal fun QuickPayLedger.recordIndex(hash: String): Int? = + records.indexOfFirst { it.invoicePaymentHash == hash || it.paymentId == hash || it.id == hash } + .takeIf { it >= 0 } + +internal 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/test/java/to/bitkit/repositories/QuickPayRepoTest.kt b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt index 8b6363d77..d62b26f41 100644 --- a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt @@ -35,6 +35,8 @@ import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.di.json import to.bitkit.models.ConvertedAmount +import to.bitkit.models.QuickPayLedger +import to.bitkit.models.QuickPayRecordPhase import to.bitkit.models.USD import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError From 7bd772a12d520e440fab68c283ad114c58b821db Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 25 Aug 2026 21:35:11 +0200 Subject: [PATCH 68/71] fix: detach quickpay sessions synchronously --- .../to/bitkit/repositories/QuickPayRepo.kt | 6 +++- .../bitkit/repositories/QuickPayRepoTest.kt | 29 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt index 48c1d93e5..844e01e72 100644 --- a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt @@ -85,11 +85,14 @@ class QuickPayRepo @Inject constructor( } fun detach(session: QuickPaySession) { + // Remove synchronously so a racing completion cannot claim delivery to a disposed UI + sessionFlows.remove(session.id) scope.launch { detachSession(session.id) } } fun detachAll() { val ids = sessionFlows.keys.toList() + ids.forEach { sessionFlows.remove(it) } scope.launch { ids.forEach { detachSession(it) } } @@ -682,7 +685,8 @@ class QuickPayRepo @Inject constructor( private fun emitToSession(sessionId: String?, event: QuickPaySessionEvent): Boolean { if (sessionId == null) return false - return sessionFlows[sessionId]?.tryEmit(event) == true + val flow = sessionFlows[sessionId] ?: return false + return flow.subscriptionCount.value > 0 && flow.tryEmit(event) } private data class InFlightOp( diff --git a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt index d62b26f41..78f756c81 100644 --- a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt @@ -221,6 +221,35 @@ class QuickPayRepoTest : BaseUnitTest() { hold.complete(Result.success("pid")) } + @Test + fun `failure racing a detached session does not claim delivery`() = test { + val (bolt11, hash) = 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).test { + backgroundScope.launch { + sut.payNow(session, QuickPayPayRequest.Bolt11(bolt11 = bolt11, amountSats = 500u)) + } + started.await() + // async op cleanup is deliberately not advanced: the completion races the detach + sut.detach(session) + + val outcome = sut.signalCompletion(paymentId = null, paymentHash = hash, success = false) + + assertFalse(outcome.sessionNotified) + expectNoEvents() + } + hold.complete(Result.success("pid")) + } + @Test fun `signalCompletion failure on a prior day does not decrement the new day`() = test { assertNotNull(sut.reserveBound("old", 1000u).getOrThrow()) From fe6c7a5e1c024afd0b3165f050b3af108a68b5bc Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 25 Aug 2026 23:48:48 +0200 Subject: [PATCH 69/71] chore: fix quickpay detekt nits --- app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt index 844e01e72..1ebeea5c1 100644 --- a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt @@ -22,7 +22,6 @@ 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 @@ -718,7 +717,6 @@ class QuickPayRepo @Inject constructor( private enum class AmbiguousApply { UNCHANGED, SUCCEEDED, FAILED } } - fun interface QuickPayInvoiceParser { fun parse(bolt11: String): String? } From 5b5d4ba60228f9ee516db6d9fd8d24e3d759fba6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 26 Aug 2026 10:37:09 +0200 Subject: [PATCH 70/71] fix: flush unacked quickpay failures on detach --- .../to/bitkit/repositories/QuickPayRepo.kt | 19 +++++- .../wallets/send/SendQuickPayScreen.kt | 5 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 6 ++ .../to/bitkit/viewmodels/QuickPayViewModel.kt | 2 + .../bitkit/repositories/QuickPayRepoTest.kt | 63 +++++++++++++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 26 ++++++++ 6 files changed, 119 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt index 1ebeea5c1..e6a36fe13 100644 --- a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt @@ -7,6 +7,8 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map @@ -65,6 +67,12 @@ class QuickPayRepo @Inject constructor( private val mutex = Mutex() private val opsByKey = mutableMapOf() private val sessionFlows = ConcurrentHashMap>() + private val unackedFailures = ConcurrentHashMap() + + private val _unhandledFailures = MutableSharedFlow(extraBufferCapacity = 8) + + /** Failures delivered to a session but never handled by its UI before detach. */ + val unhandledFailures: SharedFlow = _unhandledFailures.asSharedFlow() init { scope.launch { @@ -97,6 +105,10 @@ class QuickPayRepo @Inject constructor( } } + fun acknowledge(session: QuickPaySession) { + unackedFailures.remove(session.id) + } + fun pay(session: QuickPaySession, request: QuickPayPayRequest) { scope.launch { payNow(session, request) } } @@ -450,6 +462,7 @@ class QuickPayRepo @Inject constructor( private suspend fun detachSession(sessionId: String) { mutex.withLock { sessionFlows.remove(sessionId) + unackedFailures.remove(sessionId)?.let { _unhandledFailures.tryEmit(it) } val op = opsByKey.values.firstOrNull { it.sessionId == sessionId } ?: return@withLock if (op.sessionId != sessionId) return@withLock op.sessionId = null @@ -659,7 +672,11 @@ class QuickPayRepo @Inject constructor( return false } op.emitted = true - val notified = emitToSession(op.sessionId, QuickPaySessionEvent.Error(error, paymentRequest)) + val sessionId = op.sessionId + val notified = emitToSession(sessionId, QuickPaySessionEvent.Error(error, paymentRequest)) + if (notified && sessionId != null) { + unackedFailures[sessionId] = error + } op.settled.complete(Unit) return notified } 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 aad987ca4..b09020d4a 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 @@ -70,7 +70,10 @@ fun SendQuickPayScreen( onPaymentPending(result.paymentHash, result.amount, result.paymentRequest) } is QuickPayResult.FallBackToConfirm -> onFallBackToConfirm() - is QuickPayResult.Error -> onShowError(result.failure) + is QuickPayResult.Error -> { + viewModel.acknowledge(session) + onShowError(result.failure) + } null -> Unit // continue showing loading state } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 5329c268d..3b3b4da0a 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -157,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.QuickPayPaymentFailedError import to.bitkit.repositories.QuickPayRepo import to.bitkit.repositories.SamRockRepo import to.bitkit.repositories.TransferRepo @@ -375,6 +376,11 @@ class AppViewModel @Inject constructor( viewModelScope.launch { lightningRepo.updateGeoBlockState() } + viewModelScope.launch { + quickPayRepo.unhandledFailures.collect { + notifyPaymentFailed((it as? QuickPayPaymentFailedError)?.reason) + } + } viewModelScope.launch { hwWalletRepo.receivedTxs.collect { tx -> showTransactionSheet( diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index 184d6fc22..522f81307 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -55,6 +55,8 @@ class QuickPayViewModel @Inject constructor( } } + fun acknowledge(session: QuickPaySession) = quickPayRepo.acknowledge(session) + fun pay(session: QuickPaySession, data: QuickPayData) { if (isPayRequested || _uiState.value.result != null) return isPayRequested = true diff --git a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt index 78f756c81..8cca877fb 100644 --- a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt @@ -221,6 +221,69 @@ class QuickPayRepoTest : BaseUnitTest() { hold.complete(Result.success("pid")) } + @Test + fun `unacked session failure flushes to unhandled on detach`() = test { + val (bolt11, hash) = 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.unhandledFailures.test { + sut.attach(session).test { + backgroundScope.launch { + sut.payNow(session, QuickPayPayRequest.Bolt11(bolt11 = bolt11, amountSats = 500u)) + } + started.await() + // completion is delivered to the live session BEFORE detach, but never acknowledged + val outcome = sut.signalCompletion(paymentId = null, paymentHash = hash, success = false) + assertTrue(outcome.sessionNotified) + assertIs(awaitItem()) + } + sut.detach(session) + advanceUntilIdle() + + assertIs(awaitItem()) + } + hold.complete(Result.success("pid")) + } + + @Test + fun `acknowledged failure does not flush on detach`() = test { + val (bolt11, hash) = 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.unhandledFailures.test { + sut.attach(session).test { + backgroundScope.launch { + sut.payNow(session, QuickPayPayRequest.Bolt11(bolt11 = bolt11, amountSats = 500u)) + } + started.await() + sut.signalCompletion(paymentId = null, paymentHash = hash, success = false) + assertIs(awaitItem()) + } + sut.acknowledge(session) + sut.detach(session) + advanceUntilIdle() + + expectNoEvents() + } + hold.complete(Result.success("pid")) + } + @Test fun `failure racing a detached session does not claim delivery`() = test { val (bolt11, hash) = testInvoice() diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 41ecc2e37..ee80326f8 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -99,6 +99,7 @@ import to.bitkit.repositories.PublicPaykitPaymentResult import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.repositories.QuickPayCompletionKind import to.bitkit.repositories.QuickPayCompletionOutcome +import to.bitkit.repositories.QuickPayPaymentFailedError import to.bitkit.repositories.QuickPayRepo import to.bitkit.repositories.SamRockRepo import to.bitkit.repositories.SettledReceiveAddress @@ -163,6 +164,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val notifyChannelReadyHandler = mock() private val cacheStore = mock() private val quickPayRepo = mock() + private val quickPayUnhandledFailures = MutableSharedFlow(extraBufferCapacity = 8) private val transferRepo = mock() private val migrationService = mock() private val coreService = mock() @@ -233,6 +235,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(cacheStore.data).thenReturn(flowOf(AppCacheData())) whenever { quickPayRepo.canApply(any()) }.thenReturn(Result.success(false)) whenever { quickPayRepo.hasOpen(any()) }.thenReturn(false) + whenever(quickPayRepo.unhandledFailures).thenReturn(quickPayUnhandledFailures) whenever { quickPayRepo.signalCompletion(anyOrNull(), anyOrNull(), any(), anyOrNull(), anyOrNull()) }.thenReturn(QuickPayCompletionOutcome.None) @@ -2085,6 +2088,29 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(toastManager, never()).enqueue(any()) } + @Test + fun `unhandled QuickPay session failure flushes to a toast`() = test { + whenever(context.getString(R.string.wallet__toast_payment_failed_title)).thenReturn("Payment failed") + whenever(context.getString(R.string.wallet__payment_route_not_found)).thenReturn("no route") + advanceUntilIdle() + clearInvocations(toastManager) + + quickPayUnhandledFailures.emit( + QuickPayPaymentFailedError( + paymentHash = "unhandledhash", + reason = PaymentFailureReason.ROUTE_NOT_FOUND, + paymentRequest = "lnbcrt1unhandled", + ), + ) + advanceUntilIdle() + + verify(toastManager).enqueue( + check { + assertEquals("PaymentFailedToast", it.testTag) + } + ) + } + @Test fun `unrelated failure still toasts while QuickPay send is open`() = test { val bolt11 = "lnbcrt1quickpayunrelated" From 6af18daeae501e008f0a66ab4deee26443f20b97 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 26 Aug 2026 15:52:47 +0200 Subject: [PATCH 71/71] chore: suppress quickpay toomanyfunctions --- app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt index e6a36fe13..76c533e2b 100644 --- a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt @@ -46,7 +46,7 @@ import javax.inject.Singleton import kotlin.time.Clock @Singleton -@Suppress("LongParameterList", "LargeClass") +@Suppress("LongParameterList", "LargeClass", "TooManyFunctions") class QuickPayRepo @Inject constructor( cacheStore: CacheStore, private val settingsStore: SettingsStore,