From e179afd70ba2446dd30d5f66b1f81427b3453876 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 26 Oct 2026 19:02:18 +0200 Subject: [PATCH 1/4] fix: show cached USDT balance on Home Reuse persisted exchange rates for display while refreshing the wallet-scoped balance. Keep payment freshness and spendable state separate. Fixes #1698 --- .../main/java/to/bitkit/data/CacheStore.kt | 11 +++ .../to/bitkit/repositories/CurrencyRepo.kt | 3 + .../java/to/bitkit/repositories/UsdtRepo.kt | 96 +++++++++++++------ .../bitkit/ui/screens/wallets/HomeScreen.kt | 17 ++-- .../ui/screens/wallets/HomeWalletBalance.kt | 30 ++++++ .../screens/wallets/usdt/UsdtWalletScreen.kt | 2 +- app/src/main/res/values-ar/strings.xml | 1 + app/src/main/res/values-b+es+419/strings.xml | 1 + app/src/main/res/values-ca/strings.xml | 1 + app/src/main/res/values-cs/strings.xml | 1 + app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-el/strings.xml | 1 + app/src/main/res/values-es-rES/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-nl/strings.xml | 1 + app/src/main/res/values-pl/strings.xml | 1 + app/src/main/res/values-pt-rBR/strings.xml | 1 + app/src/main/res/values-pt/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../java/to/bitkit/data/AppCacheDataTest.kt | 12 +++ .../bitkit/repositories/CurrencyRepoTest.kt | 2 + .../repositories/UsdtEndpointTestWallet.kt | 5 +- .../repositories/UsdtPreferencesRepoTest.kt | 7 +- .../to/bitkit/repositories/UsdtRepoTest.kt | 75 +++++++++++---- .../bitkit/ui/components/UsdtDisplayTest.kt | 41 ++++++++ changelog.d/next/1700.fixed.md | 1 + journeys/usdt-cached-home-balance.md | 12 +++ 30 files changed, 272 insertions(+), 58 deletions(-) create mode 100644 app/src/main/java/to/bitkit/ui/screens/wallets/HomeWalletBalance.kt create mode 100644 changelog.d/next/1700.fixed.md create mode 100644 journeys/usdt-cached-home-balance.md diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index 9966a479f5..4f7bd9e0d5 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -176,6 +176,7 @@ data class AppCacheData( val bolt11PaymentHash: String = "", val bip21: String = "", val balance: BalanceState? = null, + val usdtDisplayBalance: CachedUsdtBalance? = null, val backupStatuses: Map = mapOf(), val deletedActivities: List = listOf(), val detachedActivityContacts: Set = emptySet(), @@ -207,3 +208,13 @@ data class BlocktankRefundAddress( val address: String, val index: Long, ) + +@Serializable +data class CachedUsdtBalance( + val address: String, + val network: String, + val balance: ULong, +) { + fun balanceFor(address: String, network: String): ULong? = + balance.takeIf { this.address == address && this.network == network } +} diff --git a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt index c1e115de79..1daf246674 100644 --- a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt @@ -137,6 +137,7 @@ class CurrencyRepo @Inject constructor( private suspend fun refresh() { if (isRefreshing) return isRefreshing = true + _currencyState.update { it.copy(isRefreshing = true) } try { runSuspendCatching { val fetchedRates = currencyService.fetchLatestRates() @@ -160,6 +161,7 @@ class CurrencyRepo @Inject constructor( } } finally { isRefreshing = false + _currencyState.update { it.copy(isRefreshing = false) } } } @@ -273,6 +275,7 @@ data class CurrencyState( val displayUnit: BitcoinDisplayUnit = BitcoinDisplayUnit.MODERN, val primaryDisplay: PrimaryDisplay = PrimaryDisplay.BITCOIN, val lastSuccessfulRefresh: Long? = null, + val isRefreshing: Boolean = false, ) val CurrencyState.paykitRate: PaykitExchangeRate? diff --git a/app/src/main/java/to/bitkit/repositories/UsdtRepo.kt b/app/src/main/java/to/bitkit/repositories/UsdtRepo.kt index 779784dc67..90f1ada486 100644 --- a/app/src/main/java/to/bitkit/repositories/UsdtRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/UsdtRepo.kt @@ -21,18 +21,23 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull +import to.bitkit.data.CacheStore +import to.bitkit.data.CachedUsdtBalance import to.bitkit.di.IoDispatcher import to.bitkit.env.Env import to.bitkit.ext.runSuspendCatching import to.bitkit.models.PaykitUsdt import to.bitkit.services.UsdtService import to.bitkit.utils.AppError +import to.bitkit.utils.Logger +import java.security.MessageDigest import java.text.DecimalFormatSymbols import java.util.Locale import javax.inject.Inject @@ -44,9 +49,16 @@ import kotlin.time.Instant @Singleton class UsdtRepo @Inject constructor( private val service: UsdtService, + private val cacheStore: CacheStore, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val clock: Clock = Clock.System, ) { + companion object { + private const val TAG = "UsdtRepo" + } + + private val displayCacheNetwork = MessageDigest.getInstance("SHA-256") + .digest("${Env.network}:${Env.usdtRpcUrl}:${Env.usdtBundlerUrl}".encodeToByteArray()).contentToString() private val mutex = Mutex() @Volatile var isSendPresented = false @@ -65,35 +77,55 @@ class UsdtRepo @Inject constructor( if (clock.now().toEpochMilliseconds() < refreshAfter) { return@withLock if (force) Result.failure(UsdtException.RateLimited()) else Result.success(Unit) } - runSuspendCatching { - val wallet = service.wallet() - val address = wallet.receiveAddress() - if (_state.value.address != address) _state.update { UsdtWalletState(address = address) } - val previous = _state.value.transfers.takeIf { _state.value.historyComplete } - val saved = wallet.history().toImmutableList() - _state.update { it.copy(receiveUri = wallet.receiveUri(), transfers = saved) } - val settlement = runSuspendCatching { - val balance = wallet.balance() - _state.update { it.copy(balance = balance) } - val transfers = wallet.refreshTransfers().toImmutableList() - _state.update { it.copy(transfers = transfers) } - } - if (settlement.exceptionOrNull() is UsdtException.RateLimited) settlement.getOrThrow() - if (history && !isSendPresented) { - val complete = wallet.syncHistory() - val current = wallet.history().toImmutableList() - _state.update { it.copy(historyComplete = it.historyComplete || complete, transfers = current) } - announceNewReceipts(previous, current) - } - settlement.getOrThrow() - }.also { - if (it.exceptionOrNull() is UsdtException.RateLimited) { - rateLimitedUntil = clock.now().toEpochMilliseconds() + 60.seconds.inWholeMilliseconds + _state.update { it.copy(refreshing = true, balanceIsStale = true) } + try { + runSuspendCatching { + val wallet = service.wallet() + val address = wallet.receiveAddress() + if (_state.value.address != address) { + _state.update { UsdtWalletState(address = address, refreshing = true) } + } + if (_state.value.balance == null) { + runSuspendCatching { cacheStore.data.first().usdtDisplayBalance } + .onSuccess { cached -> + _state.update { + it.copy(cachedBalance = cached?.balanceFor(address, displayCacheNetwork)) + } + }.onFailure { Logger.warn("Failed to load display balance", it, context = TAG) } + } + val previous = _state.value.transfers.takeIf { _state.value.historyComplete } + val saved = wallet.history().toImmutableList() + _state.update { it.copy(receiveUri = wallet.receiveUri(), transfers = saved) } + val settlement = runSuspendCatching { + val balance = wallet.balance() + _state.update { it.copy(balance = balance, balanceIsStale = false) } + runSuspendCatching { + cacheStore.update { + it.copy(usdtDisplayBalance = CachedUsdtBalance(address, displayCacheNetwork, balance)) + } + }.onFailure { Logger.warn("Failed to cache display balance", it, context = TAG) } + val transfers = wallet.refreshTransfers().toImmutableList() + _state.update { it.copy(transfers = transfers) } + } + if (settlement.exceptionOrNull() is UsdtException.RateLimited) settlement.getOrThrow() + if (history && !isSendPresented) { + val complete = wallet.syncHistory() + val current = wallet.history().toImmutableList() + _state.update { it.copy(historyComplete = it.historyComplete || complete, transfers = current) } + announceNewReceipts(previous, current) + } + settlement.getOrThrow() + }.also { + if (it.exceptionOrNull() is UsdtException.RateLimited) { + rateLimitedUntil = clock.now().toEpochMilliseconds() + 60.seconds.inWholeMilliseconds + } + nextRefresh = maxOf( + rateLimitedUntil, + clock.now().toEpochMilliseconds() + 10.seconds.inWholeMilliseconds + ) } - nextRefresh = maxOf( - rateLimitedUntil, - clock.now().toEpochMilliseconds() + 10.seconds.inWholeMilliseconds - ) + } finally { + _state.update { it.copy(refreshing = false) } } } } @@ -155,9 +187,13 @@ class UsdtRepo @Inject constructor( runSuspendCatching { val wallet = service.wallet(requiresNetwork = false) wallet.restoreBackup(snapshot) + cacheStore.update { it.copy(usdtDisplayBalance = null) } _state.update { it.copy( address = wallet.receiveAddress(), + balance = null, + cachedBalance = null, + balanceIsStale = true, transfers = wallet.history().toImmutableList(), historyComplete = false, pendingReceipts = persistentListOf(), @@ -194,6 +230,7 @@ class UsdtRepo @Inject constructor( suspend fun wipe(clearCredentials: suspend () -> Unit = {}) = withContext(ioDispatcher) { mutex.withLock { + cacheStore.update { it.copy(usdtDisplayBalance = null) } clearCredentials() runSuspendCatching { service.wipe() }.also { _state.update { UsdtWalletState() } @@ -264,6 +301,9 @@ data class UsdtWalletState( val address: String = "", val receiveUri: String = "", val balance: ULong? = null, + val cachedBalance: ULong? = null, + val balanceIsStale: Boolean = true, + val refreshing: Boolean = false, val transfers: ImmutableList = persistentListOf(), val historyComplete: Boolean = false, val pendingReceipts: ImmutableList = persistentListOf(), diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt index 5c75d59a64..3f68440496 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt @@ -100,7 +100,6 @@ import androidx.navigation.NavController import androidx.navigation.NavHostController import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.UsdtTransferStatus -import com.synonym.bitkitcore.usdtFormatAmount import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeSource @@ -126,7 +125,6 @@ import to.bitkit.models.BalanceState import to.bitkit.models.BannerItem import to.bitkit.models.HwWallet import to.bitkit.models.MoneyType -import to.bitkit.models.PaykitExchangeRate import to.bitkit.models.Suggestion import to.bitkit.models.TransportType import to.bitkit.models.WidgetSize @@ -145,6 +143,7 @@ import to.bitkit.ui.appViewModel import to.bitkit.ui.components.ActivityBanner import to.bitkit.ui.components.AppStatus import to.bitkit.ui.components.BalanceHeaderView +import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.CaptionB import to.bitkit.ui.components.EmptyStateView import to.bitkit.ui.components.FillHeight @@ -164,7 +163,6 @@ import to.bitkit.ui.components.Title import to.bitkit.ui.components.TopBarSpacer import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.components.WalletBalanceView -import to.bitkit.ui.components.usdtDisplaySats import to.bitkit.ui.navToActivityDetail import to.bitkit.ui.navigateTo import to.bitkit.ui.navigateToAllActivity @@ -623,9 +621,6 @@ private fun UsdtHomeRefreshEffect() { UsdtRefreshEffect(viewModel, pending = wallet.transfers.any { it.status == UsdtTransferStatus.PENDING }) } -private fun usdtHeadlineSats(balance: ULong?, visible: Boolean, rate: PaykitExchangeRate?): Long? = - if (visible && balance != 0uL) balance?.let { usdtDisplaySats(usdtFormatAmount(it), rate) } else 0L - @OptIn(ExperimentalMaterial3Api::class) @Composable private fun WalletPage( @@ -649,7 +644,8 @@ private fun WalletPage( val slotCapacity = if (isSmallScreen) SMALL_SCREEN_SLOT_CAPACITY else LARGE_SCREEN_SLOT_CAPACITY val showUsdt = appViewModel?.isUsdtVisible?.collectAsStateWithLifecycle()?.value == true val rate = LocalCurrencies.current.paykitRate - val usdtSats = usdtHeadlineSats(usdt.balance, showUsdt, rate) + val usdtSats = usdtHeadlineSats(usdt.balance ?: usdt.cachedBalance, showUsdt, rate) + val balanceUpdating = showUsdt && usdtBalanceUpdating(usdt, LocalCurrencies.current) val headlineSats = BigInteger( balances.totalWithHardwareSats.toString() ).add(BigInteger.valueOf(usdtSats ?: 0L)).min(BigInteger.valueOf(Long.MAX_VALUE)).toLong() @@ -692,6 +688,13 @@ private fun WalletPage( .fillMaxWidth() .testTag("TotalBalance") ) + if (balanceUpdating) { + Caption13Up( + text = stringResource(R.string.wallet__balance_updating), + color = Colors.White64, + modifier = Modifier.padding(top = 8.dp).testTag("TotalBalanceUpdating") + ) + } VerticalSpacer(32.dp) BalancesSection( balances = balances, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeWalletBalance.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeWalletBalance.kt new file mode 100644 index 0000000000..715b20dab0 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeWalletBalance.kt @@ -0,0 +1,30 @@ +package to.bitkit.ui.screens.wallets + +import to.bitkit.ext.nowMillis +import to.bitkit.models.PaykitAmount +import to.bitkit.models.PaykitAsset +import to.bitkit.models.PaykitExchangeRate +import to.bitkit.repositories.CurrencyState +import to.bitkit.repositories.UsdtWalletState +import to.bitkit.repositories.paykitRate +import to.bitkit.ui.components.usdtDisplaySats + +internal fun usdtHeadlineSats( + balance: ULong?, + visible: Boolean, + rate: PaykitExchangeRate?, + now: Long = nowMillis(), +): Long? { + if (!visible || balance == 0uL) return 0L + if (balance == null || rate == null || rate.timestampMillis > now) return null + // Home can show the last known value; payment conversions still require a fresh rate. + return usdtDisplaySats(PaykitAmount(PaykitAsset.USDT, balance).value, rate, now = rate.timestampMillis) +} + +internal fun usdtBalanceUpdating(usdt: UsdtWalletState, currency: CurrencyState, now: Long = nowMillis()): Boolean = + usdt.balanceIsStale || usdt.refreshing || + ( + usdt.balance != 0uL && ( + currency.isRefreshing || runCatching { currency.paykitRate?.value(now) }.getOrNull() == null + ) + ) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreen.kt index 07f16c65a1..8d733dcdff 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreen.kt @@ -103,7 +103,7 @@ fun RowScope.UsdtWalletBalance(onClick: () -> Unit, viewModel: UsdtViewModel = h if (settings.hideBalance) { UiConstants.HIDE_BALANCE_SHORT } else { - wallet.balance?.let { "$ " + usdtOverviewAmount(it) } ?: "—" + (wallet.balance ?: wallet.cachedBalance)?.let { "$ " + usdtOverviewAmount(it) } ?: "—" }, style = UsdtDetailStyles.HistoryTitle ) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 0894e7c3af..347f4831c6 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1732,4 +1732,5 @@ إعدادات الأداة صغير عريض + جارٍ تحديث الرصيد… diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index b051b9e1e7..1909cb5311 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -1729,4 +1729,5 @@ Ajustes del widget Pequeño Ancho + Actualizando saldo… diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index f83790081a..f80d8e1ccc 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -1729,4 +1729,5 @@ Configuració del giny Petit Ample + S’està actualitzant el saldo… diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index bb7a644e4b..320928f091 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -1730,4 +1730,5 @@ Nastavení widgetu Malý Široký + Aktualizace zůstatku… diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index efc30cca07..1f18e1a92f 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1728,4 +1728,5 @@ Widget-Einstellungen Klein Breit + Kontostand wird aktualisiert… diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 3800135d9c..54236b6eff 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -1728,4 +1728,5 @@ Ρυθμίσεις widget Μικρό Φαρδύ + Ενημέρωση υπολοίπου… diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index fc09657b40..6b53d84af6 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -1729,4 +1729,5 @@ Ajustes del widget Pequeño Ancho + Actualizando saldo… diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 5072785f9b..44b48ab09b 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1729,4 +1729,5 @@ Ajustes del widget Pequeño Ancho + Actualizando saldo… diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 6948ef3d1e..12ec18223c 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1729,4 +1729,5 @@ Paramètres du widget Petit Large + Mise à jour du solde… diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 1689758d4e..9640894d20 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1729,4 +1729,5 @@ Impostazioni Widget Piccolo Largo + Aggiornamento del saldo… diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 2cb52c4df6..c1309d8df6 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -1728,4 +1728,5 @@ Widgetinstellingen Klein Breed + Saldo bijwerken… diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 6b90799070..1f0efff06a 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1730,4 +1730,5 @@ Ustawienia widgetu Mały Szeroki + Aktualizowanie salda… diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 53396ccd4e..2ba1218bb3 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1729,4 +1729,5 @@ Configurações do Widget Pequeno Largo + Atualizando saldo… diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 33a4fed7a7..18f8c590ce 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1729,4 +1729,5 @@ Definições do widget Pequeno Largo + A atualizar o saldo… diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 1b60527452..f3e9f37990 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1757,4 +1757,5 @@ Bitkit должен увеличить приёмную ёмкость ваше Настройки Виджета Маленький Широкий + Обновление баланса… diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f3e6434bbe..b4738bdfd0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1740,4 +1740,5 @@ Small Wide Widgets + Updating balance… diff --git a/app/src/test/java/to/bitkit/data/AppCacheDataTest.kt b/app/src/test/java/to/bitkit/data/AppCacheDataTest.kt index 2401dfcb86..f539d78118 100644 --- a/app/src/test/java/to/bitkit/data/AppCacheDataTest.kt +++ b/app/src/test/java/to/bitkit/data/AppCacheDataTest.kt @@ -24,6 +24,18 @@ class AppCacheDataTest : BaseUnitTest() { "vjjsrvywa6rt52cm9r9zqt8r2t7mlcwspyetp5h2tztugp9lfyql" } + @Test + fun `cached USDT balance round trips exact values and rejects another wallet or network`() { + for (balance in listOf(0uL, 1_234_567uL, ULong.MAX_VALUE)) { + val original = AppCacheData(usdtDisplayBalance = CachedUsdtBalance("wallet-a", "network-a", balance)) + val restored = json.decodeFromString(json.encodeToString(original)).usdtDisplayBalance!! + assertEquals(balance, restored.balanceFor("wallet-a", "network-a")) + assertNull(restored.balanceFor("wallet-b", "network-a")) + assertNull(restored.balanceFor("wallet-a", "network-b")) + } + assertNull(json.decodeFromString("{}").usdtDisplayBalance) + } + @Test fun `AppCacheSerializer reads a legacy receive invoice without a stored payment hash`() = test { val cachedReceive = AppCacheSerializer.readFrom( diff --git a/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt index ce7ec0bfd5..03a20270d9 100644 --- a/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt @@ -225,7 +225,9 @@ class CurrencyRepoTest : BaseUnitTest() { sut = createSut() val job = launch { sut.triggerRefresh() } + assertTrue(sut.currencyState.value.isRefreshing) job.cancelAndJoin() + assertFalse(sut.currencyState.value.isRefreshing) assertNull(sut.currencyState.value.error) diff --git a/app/src/test/java/to/bitkit/repositories/UsdtEndpointTestWallet.kt b/app/src/test/java/to/bitkit/repositories/UsdtEndpointTestWallet.kt index 3dc840d08a..9802b75b0b 100644 --- a/app/src/test/java/to/bitkit/repositories/UsdtEndpointTestWallet.kt +++ b/app/src/test/java/to/bitkit/repositories/UsdtEndpointTestWallet.kt @@ -8,8 +8,11 @@ import kotlinx.coroutines.CoroutineDispatcher import org.mockito.Mockito.mockStatic import org.mockito.kotlin.mock import org.mockito.kotlin.whenever +import to.bitkit.data.AppCacheData +import to.bitkit.data.CacheStore import to.bitkit.data.keychain.Keychain import to.bitkit.services.UsdtService +import to.bitkit.test.InMemoryDataStore internal class UsdtEndpointTestWallet(dispatcher: CoroutineDispatcher) : AutoCloseable { companion object { @@ -22,7 +25,7 @@ internal class UsdtEndpointTestWallet(dispatcher: CoroutineDispatcher) : AutoClo var derivationAvailable = true private val keychain = mock() private val native = mockStatic(Class.forName("com.synonym.bitkitcore.Bitkitcore_androidKt")) - val repo = UsdtRepo(UsdtService(keychain, mock()), dispatcher) + val repo = UsdtRepo(UsdtService(keychain, mock()), CacheStore(InMemoryDataStore(AppCacheData())), dispatcher) init { whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenAnswer { diff --git a/app/src/test/java/to/bitkit/repositories/UsdtPreferencesRepoTest.kt b/app/src/test/java/to/bitkit/repositories/UsdtPreferencesRepoTest.kt index c7ee42e293..0cd5050008 100644 --- a/app/src/test/java/to/bitkit/repositories/UsdtPreferencesRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/UsdtPreferencesRepoTest.kt @@ -15,11 +15,14 @@ import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.whenever +import to.bitkit.data.AppCacheData +import to.bitkit.data.CacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.UsdtPreference import to.bitkit.services.UsdtService import to.bitkit.test.BaseUnitTest +import to.bitkit.test.InMemoryDataStore import to.bitkit.utils.AppError import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -158,7 +161,7 @@ class UsdtPreferencesRepoTest : BaseUnitTest() { whenever(coreWallet.receiveUri()).thenReturn("wallet-uri") whenever(coreWallet.history()).thenReturn(listOf(settled)) whenever(coreWallet.balance()).thenAnswer { throw AppError("offline") } - val usdt = UsdtRepo(service, testDispatcher) + val usdt = UsdtRepo(service, CacheStore(InMemoryDataStore(AppCacheData())), testDispatcher) assertTrue(usdt.refresh(history = false).isFailure) val preferences = UsdtPreferencesRepo(settingsStore, usdt, testDispatcher) assertEquals(null, usdt.state.value.balance) @@ -179,7 +182,7 @@ class UsdtPreferencesRepoTest : BaseUnitTest() { whenever(coreWallet.balance()).thenReturn(0u) whenever(coreWallet.history()).thenReturn(emptyList()) whenever(coreWallet.refreshTransfers()).thenReturn(emptyList()) - val usdt = UsdtRepo(service, testDispatcher) + val usdt = UsdtRepo(service, CacheStore(InMemoryDataStore(AppCacheData())), testDispatcher) usdt.refresh(history = false).getOrThrow() val pending: UsdtDeposit = mock { on { id } doReturn "pending-deposit" diff --git a/app/src/test/java/to/bitkit/repositories/UsdtRepoTest.kt b/app/src/test/java/to/bitkit/repositories/UsdtRepoTest.kt index 38b530957f..ca9f156ff8 100644 --- a/app/src/test/java/to/bitkit/repositories/UsdtRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/UsdtRepoTest.kt @@ -10,20 +10,26 @@ import com.synonym.bitkitcore.UsdtWallet import com.synonym.bitkitcore.usdtParseAmount import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.test.runCurrent import org.junit.Test import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.doReturn 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 to.bitkit.data.AppCacheData +import to.bitkit.data.CacheStore import to.bitkit.services.UsdtService import to.bitkit.test.BaseUnitTest +import to.bitkit.test.InMemoryDataStore import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.Duration.Companion.seconds @@ -39,6 +45,37 @@ class UsdtRepoTest : BaseUnitTest() { on { this.expiresAt }.thenReturn(expiresAt) } + @Test + fun `display cache survives restart and offline refresh but never becomes a spendable balance`() = test { + val cache = CacheStore(InMemoryDataStore(AppCacheData())) + whenever(service.wallet()).thenReturn(wallet) + whenever(wallet.receiveAddress()).thenReturn("wallet-a") + whenever(wallet.receiveUri()).thenReturn("uri") + whenever(wallet.history()).thenReturn(emptyList()) + whenever(wallet.refreshTransfers()).thenReturn(emptyList()) + whenever(wallet.balance()).thenReturn(1_234_567uL) + assertTrue(UsdtRepo(service, cache, testDispatcher).refresh(history = false, force = true).isSuccess) + whenever(wallet.balance()).thenThrow(UsdtException.NetworkUnavailable()) + val restarted = UsdtRepo(service, cache, testDispatcher) + assertTrue(restarted.refresh(history = false, force = true).isFailure) + assertEquals(1_234_567uL, restarted.state.value.cachedBalance) + assertNull(restarted.state.value.balance) + assertTrue(restarted.state.value.balanceIsStale) + assertFalse(restarted.state.value.refreshing) + doReturn(0uL).whenever(wallet).balance() + assertTrue(restarted.refresh(history = false, force = true).isSuccess) + assertEquals(0uL, restarted.state.value.balance) + assertFalse(restarted.state.value.balanceIsStale) + assertEquals(0uL, cache.data.first().usdtDisplayBalance?.balance) + whenever(wallet.receiveAddress()).thenReturn("wallet-b") + whenever(wallet.balance()).thenThrow(UsdtException.NetworkUnavailable()) + assertTrue(restarted.refresh(history = false, force = true).isFailure) + assertNull(restarted.state.value.cachedBalance) + assertNull(restarted.state.value.balance) + restarted.wipe().getOrThrow() + assertNull(cache.data.first().usdtDisplayBalance) + } + @Test fun `quote rate limits pause manual payment retries and refresh until the cooldown expires`() = test { var now = Instant.fromEpochSeconds(1_800_000_000) @@ -47,7 +84,7 @@ class UsdtRepoTest : BaseUnitTest() { whenever(wallet.receiveAddress()).thenReturn("sender") whenever(wallet.quoteMaxTransfer("recipient", UsdtDestination.ARBITRUM)) .thenThrow(UsdtException.RateLimited()).thenReturn(quote()) - val repo = UsdtRepo(service, testDispatcher, clock) + val repo = UsdtRepo(service, CacheStore(InMemoryDataStore(AppCacheData())), testDispatcher, clock) assertTrue( repo.quoteMaximum("recipient", UsdtDestination.ARBITRUM).exceptionOrNull() is UsdtException.RateLimited ) @@ -71,7 +108,7 @@ class UsdtRepoTest : BaseUnitTest() { @Test fun `unused disabled wallet does not refresh but stored recovery and opt in do`() { - val repo = UsdtRepo(service, testDispatcher) + val repo = UsdtRepo(service, CacheStore(InMemoryDataStore(AppCacheData())), testDispatcher) assertEquals(false, repo.shouldRefresh(enabled = false)) assertTrue(repo.shouldRefresh(enabled = true)) whenever(service.hasStoredWallet).thenReturn(true) @@ -84,7 +121,7 @@ class UsdtRepoTest : BaseUnitTest() { val address = "0x18503bD685aF84E6A7bC289898bAa1C3f5d8527E" whenever(service.wallet()).thenReturn(wallet) whenever(wallet.receiveAddress()).thenReturn(address) - val repo = UsdtRepo(service, testDispatcher) + val repo = UsdtRepo(service, CacheStore(InMemoryDataStore(AppCacheData())), testDispatcher) val recipient = " ${address.lowercase()} " assertTrue(repo.quote(recipient, "1", UsdtDestination.ARBITRUM).exceptionOrNull() is UsdtSelfSendError) assertTrue(repo.quoteMaximum(recipient, UsdtDestination.ARBITRUM).exceptionOrNull() is UsdtSelfSendError) @@ -102,7 +139,7 @@ class UsdtRepoTest : BaseUnitTest() { @Test fun `known unsettled deposits protect the wallet until their updated status is settled`() = test { - val repo = UsdtRepo(service, testDispatcher) + val repo = UsdtRepo(service, CacheStore(InMemoryDataStore(AppCacheData())), testDispatcher) val pending: UsdtDeposit = mock { on { id }.thenReturn("deposit-one") on { status }.thenReturn("confirming") @@ -131,7 +168,7 @@ class UsdtRepoTest : BaseUnitTest() { finish.await() emptyList() } - val repo = UsdtRepo(service, testDispatcher) + val repo = UsdtRepo(service, CacheStore(InMemoryDataStore(AppCacheData())), testDispatcher) val deposits = UsdtDepositRepo(repo) val request = launch { deposits.depositNetworks().getOrThrow() } started.await() @@ -156,7 +193,7 @@ class UsdtRepoTest : BaseUnitTest() { whenever(wallet.receiveUri()).thenReturn("uri") whenever(wallet.history()).thenReturn(listOf(transfer)) whenever(wallet.balance()).thenThrow(UsdtException.NetworkUnavailable()) - val repo = UsdtRepo(service, testDispatcher) + val repo = UsdtRepo(service, CacheStore(InMemoryDataStore(AppCacheData())), testDispatcher) assertTrue(repo.refresh(true, force = true).isFailure) assertEquals(listOf(transfer), repo.state.value.transfers) assertEquals("address", repo.state.value.address) @@ -171,7 +208,7 @@ class UsdtRepoTest : BaseUnitTest() { whenever(wallet.balance()).thenReturn(1_000_000uL) whenever(wallet.refreshTransfers()).thenThrow(UsdtException.NetworkUnavailable()) whenever(wallet.syncHistory()).thenReturn(true) - val repo = UsdtRepo(service, testDispatcher) + val repo = UsdtRepo(service, CacheStore(InMemoryDataStore(AppCacheData())), testDispatcher) assertTrue(repo.refresh(true, force = true).isFailure) assertEquals(listOf(incoming("received")), repo.state.value.transfers) assertTrue(repo.state.value.historyComplete) @@ -186,7 +223,7 @@ class UsdtRepoTest : BaseUnitTest() { whenever(wallet.history()).thenReturn(emptyList()) whenever(wallet.balance()).thenReturn(1_000_000uL) whenever(wallet.refreshTransfers()).thenThrow(UsdtException.RateLimited()) - val repo = UsdtRepo(service, testDispatcher) + val repo = UsdtRepo(service, CacheStore(InMemoryDataStore(AppCacheData())), testDispatcher) assertTrue(repo.refresh(true, force = true).exceptionOrNull() is UsdtException.RateLimited) verify(wallet, never()).syncHistory() } @@ -197,9 +234,9 @@ class UsdtRepoTest : BaseUnitTest() { whenever(wallet.receiveAddress()).thenReturn("address") whenever(wallet.receiveUri()).thenReturn("uri") whenever(wallet.history()).thenReturn(emptyList()) - whenever(wallet.balance()).thenReturn(0uL) + doReturn(0uL).whenever(wallet).balance() whenever(wallet.refreshTransfers()).thenThrow(UsdtException.RateLimited()) - val repo = UsdtRepo(service, testDispatcher) + val repo = UsdtRepo(service, CacheStore(InMemoryDataStore(AppCacheData())), testDispatcher) assertTrue(repo.refresh(history = false, force = true).isFailure) assertTrue(repo.refresh(history = false, force = true).exceptionOrNull() is UsdtException.RateLimited) verify(wallet).balance() @@ -216,7 +253,7 @@ class UsdtRepoTest : BaseUnitTest() { whenever(wallet.syncHistory()).thenReturn(true) val balance = CompletableDeferred() whenever(wallet.balance()).doSuspendableAnswer { balance.await() } - val repo = UsdtRepo(service, testDispatcher) + val repo = UsdtRepo(service, CacheStore(InMemoryDataStore(AppCacheData())), testDispatcher) val refresh = launch { repo.refresh(true, force = true).getOrThrow() } runCurrent() repo.isSendPresented = true @@ -240,7 +277,7 @@ class UsdtRepoTest : BaseUnitTest() { release.await() emptyList() } - val repo = UsdtRepo(service, testDispatcher) + val repo = UsdtRepo(service, CacheStore(InMemoryDataStore(AppCacheData())), testDispatcher) val preceding = launch { UsdtDepositRepo(repo).depositNetworks().getOrThrow() } runCurrent() val quote = quote() @@ -275,7 +312,7 @@ class UsdtRepoTest : BaseUnitTest() { ) val deadline = Instant.parse("2026-10-12T12:00:00.123456789Z") whenever(service.send(quote, deadline)).thenReturn(submitted) - val repo = UsdtRepo(service, testDispatcher) + val repo = UsdtRepo(service, CacheStore(InMemoryDataStore(AppCacheData())), testDispatcher) assertEquals(submitted, repo.send(quote, deadline).getOrThrow()) verify(service).send(quote, deadline) assertEquals(listOf(submitted), repo.state.value.transfers) @@ -293,7 +330,7 @@ class UsdtRepoTest : BaseUnitTest() { whenever(service.send(quote)).thenAnswer { throw error } whenever(service.wallet()).thenReturn(wallet) whenever(wallet.history()).thenReturn(listOf(queued)) - val repo = UsdtRepo(service, testDispatcher) + val repo = UsdtRepo(service, CacheStore(InMemoryDataStore(AppCacheData())), testDispatcher) assertEquals(error, repo.send(quote).exceptionOrNull()) assertEquals(listOf(queued), repo.state.value.transfers) @@ -309,7 +346,7 @@ class UsdtRepoTest : BaseUnitTest() { release.await() emptyList() } - val repo = UsdtRepo(service, testDispatcher, clock) + val repo = UsdtRepo(service, CacheStore(InMemoryDataStore(AppCacheData())), testDispatcher, clock) val preceding = launch { UsdtDepositRepo(repo).depositNetworks().getOrThrow() } val quote = quote(now.epochSeconds.toULong() + 120u) var authorized = false @@ -332,7 +369,7 @@ class UsdtRepoTest : BaseUnitTest() { var now = Instant.fromEpochSeconds(1_800_000_000) val clock = object : Clock { override fun now() = now } val quote = quote(now.epochSeconds.toULong() + 120u) - val repo = UsdtRepo(service, testDispatcher, clock) + val repo = UsdtRepo(service, CacheStore(InMemoryDataStore(AppCacheData())), testDispatcher, clock) val result = repo.send(quote) { now += 120.seconds } @@ -351,7 +388,7 @@ class UsdtRepoTest : BaseUnitTest() { var history = listOf(old) whenever(wallet.history()).thenAnswer { history } whenever(wallet.refreshTransfers()).thenAnswer { history } - val repo = UsdtRepo(service, testDispatcher) + val repo = UsdtRepo(service, CacheStore(InMemoryDataStore(AppCacheData())), testDispatcher) repo.refresh(true, force = true).getOrThrow() assertTrue(repo.state.value.pendingReceipts.isEmpty()) val received = incoming("new") @@ -388,7 +425,7 @@ class UsdtRepoTest : BaseUnitTest() { whenever(wallet.balance()).thenReturn(1_000_000uL) whenever(wallet.refreshTransfers()).thenReturn(emptyList()) whenever(wallet.syncHistory()).thenReturn(false, true) - val repo = UsdtRepo(service, testDispatcher) + val repo = UsdtRepo(service, CacheStore(InMemoryDataStore(AppCacheData())), testDispatcher) repo.refresh(true, force = true).getOrThrow() assertEquals(1_000_000uL, repo.state.value.balance) assertEquals(listOf(incoming("old")), repo.state.value.transfers) @@ -408,7 +445,7 @@ class UsdtRepoTest : BaseUnitTest() { val sent = incoming("sent").copy(isIncoming = false) whenever(wallet.checkRecentExecution("sent")).thenReturn(sent) whenever(wallet.checkRecentExecution("limited")).thenThrow(UsdtException.RateLimited()) - val repo = UsdtRepo(service, testDispatcher) + val repo = UsdtRepo(service, CacheStore(InMemoryDataStore(AppCacheData())), testDispatcher) repo.refresh(false).getOrThrow() repo.waitForTransfer("sent").getOrThrow() assertEquals(listOf(sent), repo.state.value.transfers) diff --git a/app/src/test/java/to/bitkit/ui/components/UsdtDisplayTest.kt b/app/src/test/java/to/bitkit/ui/components/UsdtDisplayTest.kt index 49f02ce6cc..4d4c13e0cb 100644 --- a/app/src/test/java/to/bitkit/ui/components/UsdtDisplayTest.kt +++ b/app/src/test/java/to/bitkit/ui/components/UsdtDisplayTest.kt @@ -1,12 +1,53 @@ package to.bitkit.ui.components +import kotlinx.collections.immutable.persistentListOf import org.junit.Test +import to.bitkit.models.FxRate import to.bitkit.models.PaykitExchangeRate +import to.bitkit.repositories.CurrencyState +import to.bitkit.repositories.UsdtWalletState +import to.bitkit.ui.screens.wallets.usdtBalanceUpdating +import to.bitkit.ui.screens.wallets.usdtHeadlineSats import java.util.Locale import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull +import kotlin.test.assertTrue class UsdtDisplayTest { + @Test + fun `updating remains visible until balance and rates finish`() { + val now = 1_800_000_000_000L + val currency = CurrencyState( + rates = persistentListOf( + FxRate( + symbol = "BTCUSD", lastPrice = "100000", base = "BTC", + baseName = "Bitcoin", quote = "USD", quoteName = "US Dollar", currencySymbol = "$", + currencyFlag = "", lastUpdatedAt = now - 300_000 + ) + ), + isRefreshing = true, + ) + val usdt = UsdtWalletState(balance = 1_000_000uL, balanceIsStale = false, refreshing = true) + assertTrue(usdtBalanceUpdating(usdt, currency, now)) + assertTrue(usdtBalanceUpdating(usdt.copy(refreshing = false), currency, now)) + assertFalse(usdtBalanceUpdating(usdt.copy(refreshing = false), currency.copy(isRefreshing = false), now)) + } + + @Test + fun `Home uses old cached rates without making them valid for payments`() { + val now = 1_800_000_000_000L + val oldRate = PaykitExchangeRate("100000", now - 3_600_000) + assertEquals(1234L, usdtHeadlineSats(1_234_567uL, true, oldRate, now)) + assertNull(usdtDisplaySats("1.234567", oldRate, now)) + assertNull(usdtHeadlineSats(null, true, oldRate, now)) + assertNull(usdtHeadlineSats(1uL, true, null, now)) + assertNull(usdtHeadlineSats(1uL, true, oldRate.copy(timestampMillis = now + 1), now)) + assertNull(usdtHeadlineSats(1uL, true, oldRate.copy(price = "0"), now)) + assertEquals(0L, usdtHeadlineSats(0uL, true, null, now)) + assertEquals(0L, usdtHeadlineSats(null, false, null, now)) + } + @Test fun `exact amounts preserve token precision across regions`() { listOf(Locale.US, Locale.GERMANY, Locale.forLanguageTag("nl-NL"), Locale.FRANCE).forEach { locale -> diff --git a/changelog.d/next/1700.fixed.md b/changelog.d/next/1700.fixed.md new file mode 100644 index 0000000000..b1edc0d228 --- /dev/null +++ b/changelog.d/next/1700.fixed.md @@ -0,0 +1 @@ +Show the last known Home balance while USDT balances and exchange rates refresh. diff --git a/journeys/usdt-cached-home-balance.md b/journeys/usdt-cached-home-balance.md new file mode 100644 index 0000000000..952b9ad61a --- /dev/null +++ b/journeys/usdt-cached-home-balance.md @@ -0,0 +1,12 @@ +# Cached USDT Home balance + +This manual recipe requires a disposable wallet with an existing USDT display snapshot and cached exchange rates, plus control over its RPC and exchange-rate connectivity. Network fault injection and aged cache preparation are not provided by the standard journey environment. No payment or funding is required: an explicitly prepared display snapshot is not an on-chain balance and must be identified as such in the test report. + +1. Record the wallet address, network, USDT amount, BTC/USD rate and Bitcoin balances in the prepared snapshot. Keep the rate more than ten minutes old and the snapshot isolated from other wallets. +2. With refresh endpoints unavailable, cold-launch the app and open Home. The headline must include the last known USDT conversion, the USDT tile must show the cached token amount, and `TotalBalanceUpdating` must be visible. A wallet without a usable snapshot must keep the total unknown instead of silently omitting USDT. +3. Hide balances using the eye control. Both headline and USDT tile must remain hidden. Show them again and confirm the cached amounts are unchanged. +4. Restore the owned fixture endpoints with a different current balance/rate. The displayed total must update and the updating indicator must disappear once both are current. Repeat with a fresh zero balance; the headline must show the Bitcoin total even without a USDT conversion rate. +5. In a separate disposable install using different wallet credentials or network endpoints, the first wallet's snapshot must not appear. Do not reset an existing wallet to perform this check. +6. With only an expired rate available, open an unfunded payment amount flow and attempt conversion/review without submitting. The display cache must not authorize a quote, conversion or payment that requires a fresh rate. + +Record the app revision, device, snapshot provenance and each observed result. A seeded display snapshot proves rendering and restart behavior; it does not prove a funded transfer, provider operation or settlement. From 7bc486f2d0eccbb064ef4ec2b8aa9c7e65bb6d30 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 26 Oct 2026 19:45:48 +0200 Subject: [PATCH 2/4] fix: preserve USDT cleanup when display cache clearing fails --- .../java/to/bitkit/repositories/UsdtRepo.kt | 9 +++- .../to/bitkit/repositories/UsdtRepoTest.kt | 53 +++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/UsdtRepo.kt b/app/src/main/java/to/bitkit/repositories/UsdtRepo.kt index 90f1ada486..751487a428 100644 --- a/app/src/main/java/to/bitkit/repositories/UsdtRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/UsdtRepo.kt @@ -187,7 +187,7 @@ class UsdtRepo @Inject constructor( runSuspendCatching { val wallet = service.wallet(requiresNetwork = false) wallet.restoreBackup(snapshot) - cacheStore.update { it.copy(usdtDisplayBalance = null) } + clearDisplayBalance() _state.update { it.copy( address = wallet.receiveAddress(), @@ -230,7 +230,7 @@ class UsdtRepo @Inject constructor( suspend fun wipe(clearCredentials: suspend () -> Unit = {}) = withContext(ioDispatcher) { mutex.withLock { - cacheStore.update { it.copy(usdtDisplayBalance = null) } + clearDisplayBalance() clearCredentials() runSuspendCatching { service.wipe() }.also { _state.update { UsdtWalletState() } @@ -240,6 +240,11 @@ class UsdtRepo @Inject constructor( } } + private suspend fun clearDisplayBalance() { + runSuspendCatching { cacheStore.update { it.copy(usdtDisplayBalance = null) } } + .onFailure { Logger.warn("Failed to clear display balance", it, context = TAG) } + } + suspend fun quote( recipient: String, amount: String, diff --git a/app/src/test/java/to/bitkit/repositories/UsdtRepoTest.kt b/app/src/test/java/to/bitkit/repositories/UsdtRepoTest.kt index ca9f156ff8..e35802ccc4 100644 --- a/app/src/test/java/to/bitkit/repositories/UsdtRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/UsdtRepoTest.kt @@ -1,5 +1,6 @@ package to.bitkit.repositories +import androidx.datastore.core.DataStore import com.synonym.bitkitcore.UsdtDeposit import com.synonym.bitkitcore.UsdtDestination import com.synonym.bitkitcore.UsdtException @@ -11,6 +12,7 @@ import com.synonym.bitkitcore.usdtParseAmount import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import kotlinx.coroutines.test.runCurrent import org.junit.Test @@ -27,6 +29,7 @@ import to.bitkit.data.CacheStore import to.bitkit.services.UsdtService import to.bitkit.test.BaseUnitTest import to.bitkit.test.InMemoryDataStore +import java.io.IOException import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull @@ -76,6 +79,56 @@ class UsdtRepoTest : BaseUnitTest() { assertNull(cache.data.first().usdtDisplayBalance) } + @Test + fun `display cache write failure does not prevent credential and wallet cleanup`() = test { + val repo = UsdtRepo(service, failingCache(), testDispatcher) + val pending: UsdtDeposit = mock { + on { id }.thenReturn("pending") + on { status }.thenReturn("confirming") + } + repo.updateDeposits(listOf(pending)) + var credentialsCleared = false + + repo.wipe { credentialsCleared = true }.getOrThrow() + + assertTrue(credentialsCleared) + verify(service).wipe() + assertEquals(UsdtWalletState(), repo.state.value) + val walletError = UsdtException.Storage("wallet cleanup failed") + whenever(service.wipe()).thenAnswer { throw walletError } + assertEquals(walletError, repo.wipe().exceptionOrNull()) + } + + @Test + fun `display cache write failure does not leave old state after wallet restoration`() = test { + whenever(service.wallet()).thenReturn(wallet) + whenever(service.wallet(requiresNetwork = false)).thenReturn(wallet) + whenever(wallet.receiveAddress()).thenReturn("original") + whenever(wallet.receiveUri()).thenReturn("uri") + whenever(wallet.history()).thenReturn(emptyList()) + whenever(wallet.refreshTransfers()).thenReturn(emptyList()) + whenever(wallet.balance()).thenReturn(1_000_000uL) + val repo = UsdtRepo(service, failingCache(), testDispatcher) + repo.refresh(history = false, force = true).getOrThrow() + whenever(wallet.receiveAddress()).thenReturn("restored") + whenever(wallet.history()).thenReturn(listOf(incoming("restored-payment"))) + + repo.restoreBackup("snapshot").getOrThrow() + + verify(wallet).restoreBackup("snapshot") + assertEquals("restored", repo.state.value.address) + assertEquals(listOf(incoming("restored-payment")), repo.state.value.transfers) + assertNull(repo.state.value.balance) + assertNull(repo.state.value.cachedBalance) + assertTrue(repo.state.value.balanceIsStale) + } + + private fun failingCache() = CacheStore(object : DataStore { + override val data = flowOf(AppCacheData()) + override suspend fun updateData(transform: suspend (AppCacheData) -> AppCacheData): AppCacheData = + throw IOException("cache unavailable") + }) + @Test fun `quote rate limits pause manual payment retries and refresh until the cooldown expires`() = test { var now = Instant.fromEpochSeconds(1_800_000_000) From 4d7d05c5d289e603f8242384436068d6bf24b30f Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 27 Oct 2026 04:04:18 +0200 Subject: [PATCH 3/4] fix: align home empty state with cached usdt balances --- .../bitkit/ui/screens/wallets/HomeViewModel.kt | 2 +- .../ui/screens/wallets/HomeViewModelTest.kt | 16 ++++++++++++++++ journeys/usdt-cached-home-balance.md | 4 ++-- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt index 57a6b36f7e..afee832b40 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt @@ -136,7 +136,7 @@ class HomeViewModel @Inject constructor( }.combine( combine(settingsStore.data, usdtRepo.state) { settings, usdt -> Env.isUsdtEnabled && (settings.isUsdtEnabled || usdt.hasFundsOrPendingTransfers) && - ((usdt.balance ?: 0uL) > 0uL || usdt.transfers.isNotEmpty()) + ((usdt.balance ?: usdt.cachedBalance ?: 0uL) > 0uL || usdt.transfers.isNotEmpty()) } ) { hasActivity, hasUsdtActivity -> hasActivity || hasUsdtActivity diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/HomeViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/HomeViewModelTest.kt index 1c0cb0bb4a..0abbe0741b 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/HomeViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/HomeViewModelTest.kt @@ -16,6 +16,7 @@ import org.mockito.kotlin.whenever import to.bitkit.R import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore +import to.bitkit.data.UsdtPreference import to.bitkit.data.WidgetsData import to.bitkit.env.Env import to.bitkit.models.BalanceState @@ -116,6 +117,8 @@ class HomeViewModelTest : BaseUnitTest() { @Test fun `empty state follows the enabled USDT balance`() = test { + val settings = MutableStateFlow(SettingsData(usdtPreference = UsdtPreference.ENABLED)) + whenever(settingsStore.data).thenReturn(settings) val sut = createViewModel() advanceUntilIdle() assertTrue(sut.uiState.value.showEmptyState) @@ -123,6 +126,19 @@ class HomeViewModelTest : BaseUnitTest() { usdtState.value = UsdtWalletState(balance = 50_000uL) advanceUntilIdle() assertEquals(!Env.isUsdtEnabled, sut.uiState.value.showEmptyState) + + usdtState.value = UsdtWalletState(cachedBalance = 50_000uL) + advanceUntilIdle() + assertEquals(!Env.isUsdtEnabled, sut.uiState.value.showEmptyState) + + usdtState.value = UsdtWalletState(balance = 0uL, cachedBalance = 50_000uL) + advanceUntilIdle() + assertTrue(sut.uiState.value.showEmptyState) + + usdtState.value = UsdtWalletState(cachedBalance = 50_000uL) + settings.value = SettingsData(usdtPreference = UsdtPreference.DISABLED) + advanceUntilIdle() + assertTrue(sut.uiState.value.showEmptyState) } @Test diff --git a/journeys/usdt-cached-home-balance.md b/journeys/usdt-cached-home-balance.md index 952b9ad61a..da45589fd7 100644 --- a/journeys/usdt-cached-home-balance.md +++ b/journeys/usdt-cached-home-balance.md @@ -3,9 +3,9 @@ This manual recipe requires a disposable wallet with an existing USDT display snapshot and cached exchange rates, plus control over its RPC and exchange-rate connectivity. Network fault injection and aged cache preparation are not provided by the standard journey environment. No payment or funding is required: an explicitly prepared display snapshot is not an on-chain balance and must be identified as such in the test report. 1. Record the wallet address, network, USDT amount, BTC/USD rate and Bitcoin balances in the prepared snapshot. Keep the rate more than ten minutes old and the snapshot isolated from other wallets. -2. With refresh endpoints unavailable, cold-launch the app and open Home. The headline must include the last known USDT conversion, the USDT tile must show the cached token amount, and `TotalBalanceUpdating` must be visible. A wallet without a usable snapshot must keep the total unknown instead of silently omitting USDT. +2. With refresh endpoints unavailable, cold-launch the app and open Home. The headline must include the last known USDT conversion, the USDT tile must show the cached token amount, and `TotalBalanceUpdating` must be visible. With a positive cached balance and no activity, the empty-wallet funding prompt must stay hidden. A wallet without a usable snapshot must keep the total unknown instead of silently omitting USDT. 3. Hide balances using the eye control. Both headline and USDT tile must remain hidden. Show them again and confirm the cached amounts are unchanged. -4. Restore the owned fixture endpoints with a different current balance/rate. The displayed total must update and the updating indicator must disappear once both are current. Repeat with a fresh zero balance; the headline must show the Bitcoin total even without a USDT conversion rate. +4. Restore the owned fixture endpoints with a different current balance/rate. The displayed total must update and the updating indicator must disappear once both are current. Keep the balance and rate responses successful while making the history endpoint fail; the updating indicator must still clear after refresh finishes. Repeat with a failed balance read; the previous amount must remain marked as updating. Repeat with a fresh zero balance; the headline must show the Bitcoin total even without a USDT conversion rate, and an otherwise empty wallet must show its funding prompt. 5. In a separate disposable install using different wallet credentials or network endpoints, the first wallet's snapshot must not appear. Do not reset an existing wallet to perform this check. 6. With only an expired rate available, open an unfunded payment amount flow and attempt conversion/review without submitting. The display cache must not authorize a quote, conversion or payment that requires a fresh rate. From e42492c69db0d8a6bfff4c88d903030c7b597b4e Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 27 Oct 2026 11:41:45 +0200 Subject: [PATCH 4/4] fix: preserve currency refresh state --- .../to/bitkit/repositories/CurrencyRepo.kt | 24 ++++---- .../bitkit/repositories/CurrencyRepoTest.kt | 58 +++++++++++++++++++ 2 files changed, 70 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt index 1daf246674..06cefa01a7 100644 --- a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt @@ -111,21 +111,21 @@ class CurrencyRepo @Inject constructor( combine( settingsStore.data.distinctUntilChanged(), cacheStore.data.distinctUntilChanged() - ) { settings, cachedData -> + ) { settings, cachedData -> settings to cachedData }.collect { (settings, cachedData) -> val selectedRate = cachedData.cachedRates.firstOrNull { rate -> rate.quote == settings.selectedCurrency } - _currencyState.value.copy( - rates = cachedData.cachedRates.toImmutableList(), - selectedCurrency = settings.selectedCurrency, - displayUnit = settings.displayUnit, - primaryDisplay = settings.primaryDisplay, - currencySymbol = selectedRate?.currencySymbol ?: "$", - error = null, - hasStaleData = false - ) - }.collect { newState -> - _currencyState.update { newState } + _currencyState.update { + it.copy( + rates = cachedData.cachedRates.toImmutableList(), + selectedCurrency = settings.selectedCurrency, + displayUnit = settings.displayUnit, + primaryDisplay = settings.primaryDisplay, + currencySymbol = selectedRate?.currencySymbol ?: "$", + error = null, + hasStaleData = false, + ) + } } } } diff --git a/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt index 03a20270d9..342cce6162 100644 --- a/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt @@ -2,10 +2,16 @@ package to.bitkit.repositories import app.cash.turbine.test import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import org.junit.Before import org.junit.Test import org.mockito.kotlin.any @@ -25,6 +31,8 @@ import to.bitkit.services.CurrencyService import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError import java.math.BigDecimal +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull @@ -32,6 +40,7 @@ import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime @OptIn(ExperimentalTime::class) @@ -236,6 +245,55 @@ class CurrencyRepoTest : BaseUnitTest() { assertNull(sut.currencyState.value.error) verify(currencyService, times(2)).fetchLatestRates() } + + @Test + fun `cached rates should preserve completed refresh state`() = test { + val cacheData = MutableStateFlow(AppCacheData()) + whenever(cacheStore.data).thenReturn(cacheData) + whenever(settingsStore.data).thenReturn(flowOf(SettingsData(selectedCurrency = "EUR"))) + val refreshedAt = Clock.System.now() + whenever(clock.now()).thenReturn(refreshedAt) + whenever(currencyService.fetchLatestRates()).thenReturn(testRates) + val collectorRead = CompletableDeferred() + val releaseCollector = CountDownLatch(1) + var ratesReads = 0 + val refreshedCache = mock() + whenever(refreshedCache.cachedRates).thenAnswer { + ratesReads++ + if (ratesReads == 2) { + collectorRead.complete(Unit) + assertTrue(releaseCollector.await(5, TimeUnit.SECONDS)) + } + testRates + } + whenever(cacheStore.update(any())).doSuspendableAnswer { + cacheData.update { refreshedCache } + collectorRead.await() + } + sut = CurrencyRepo( + bgDispatcher = Dispatchers.Default, + currencyService = currencyService, + settingsStore = settingsStore, + cacheStore = cacheStore, + enablePolling = false, + clock = clock, + ) + + try { + withContext(Dispatchers.Default) { + withTimeout(5.seconds) { + sut.currencyState.first { it.selectedCurrency == "EUR" } + sut.triggerRefresh() + releaseCollector.countDown() + sut.currencyState.first { it.rates == testRates } + } + } + assertFalse(sut.currencyState.value.isRefreshing) + assertEquals(refreshedAt.toEpochMilliseconds(), sut.currencyState.value.lastSuccessfulRefresh) + } finally { + releaseCollector.countDown() + } + } } private class CurrencyRepoTestError(message: String) : AppError(message)