diff --git a/app/src/main/java/to/bitkit/data/widgets/WeatherService.kt b/app/src/main/java/to/bitkit/data/widgets/WeatherService.kt index 83604f4bd..6f9914842 100644 --- a/app/src/main/java/to/bitkit/data/widgets/WeatherService.kt +++ b/app/src/main/java/to/bitkit/data/widgets/WeatherService.kt @@ -11,9 +11,11 @@ import to.bitkit.data.dto.FeeEstimates import to.bitkit.data.dto.WeatherDTO import to.bitkit.env.Env import to.bitkit.models.WidgetType +import to.bitkit.repositories.CurrencyRepo import to.bitkit.services.CurrencyService import to.bitkit.utils.AppError import to.bitkit.utils.Logger +import java.math.BigDecimal import javax.inject.Inject import javax.inject.Singleton import kotlin.math.floor @@ -23,7 +25,7 @@ import kotlin.time.Duration.Companion.minutes @Singleton class WeatherService @Inject constructor( private val client: HttpClient, - private val currencyService: CurrencyService, + private val currencyRepo: CurrencyRepo, ) : WidgetService { override val widgetType = WidgetType.WEATHER @@ -92,9 +94,9 @@ class WeatherService @Inject constructor( // Check USD threshold first val avgFeeSats = currentFeeRate * AVERAGE_SEGWIT_VBYTES_SIZE - val avgFeeUsd = currencyService.convertSatsToFiat(avgFeeSats.toLong(), currency = USD_CURRENCY) + val avgFeeUsd = currencyRepo.convertSatsToFiat(avgFeeSats.toLong(), currency = USD_CURRENCY).getOrNull() ?: return FeeCondition.AVERAGE - if (avgFeeUsd <= USD_GOOD_THRESHOLD) { + if (avgFeeUsd.value <= BigDecimal(USD_GOOD_THRESHOLD)) { return FeeCondition.GOOD } @@ -107,13 +109,8 @@ class WeatherService @Inject constructor( } private suspend fun formatFeeForDisplay(satoshis: Int): String { - val usdValue = convertSatsToUsd(satoshis) - return "$ ${String.format("%.2f", usdValue)}" - } - - private suspend fun convertSatsToUsd(satoshis: Int): Double { - val amountInUsd = currencyService.convertSatsToFiat(satoshis.toLong(), currency = USD_CURRENCY) - return amountInUsd + val usdValue = currencyRepo.convertSatsToFiat(satoshis.toLong(), currency = USD_CURRENCY).getOrNull() + return usdValue?.formatted.orEmpty() } } /** diff --git a/app/src/main/java/to/bitkit/di/EnablePolling.kt b/app/src/main/java/to/bitkit/di/EnablePolling.kt new file mode 100644 index 000000000..f1d65fb49 --- /dev/null +++ b/app/src/main/java/to/bitkit/di/EnablePolling.kt @@ -0,0 +1,17 @@ +package to.bitkit.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Named + + +@Module +@InstallIn(SingletonComponent::class) +object CurrencyModule { + + @Provides + @Named("enablePolling") + fun provideEnablePolling(): Boolean = true +} diff --git a/app/src/main/java/to/bitkit/di/EnvModule.kt b/app/src/main/java/to/bitkit/di/EnvModule.kt index eff7b6de4..f7b3c187c 100644 --- a/app/src/main/java/to/bitkit/di/EnvModule.kt +++ b/app/src/main/java/to/bitkit/di/EnvModule.kt @@ -6,6 +6,7 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import kotlinx.datetime.Clock import org.lightningdevkit.ldknode.Network import to.bitkit.env.Env @@ -17,4 +18,9 @@ object EnvModule { fun provideNetwork(): Network { return Env.network } + + @Provides + fun provideClock(): Clock { + return Clock.System + } } diff --git a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt index b45e97a42..85aa8af91 100644 --- a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.datetime.Clock import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.di.BgDispatcher @@ -26,12 +27,16 @@ import to.bitkit.models.BitcoinDisplayUnit import to.bitkit.models.ConvertedAmount import to.bitkit.models.FxRate import to.bitkit.models.PrimaryDisplay +import to.bitkit.models.SATS_IN_BTC import to.bitkit.models.Toast import to.bitkit.services.CurrencyService import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.ui.utils.formatCurrency import to.bitkit.utils.Logger -import java.util.Date +import java.math.BigDecimal +import java.math.RoundingMode import javax.inject.Inject +import javax.inject.Named import javax.inject.Singleton @Singleton @@ -39,17 +44,18 @@ class CurrencyRepo @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val currencyService: CurrencyService, private val settingsStore: SettingsStore, - private val cacheStore: CacheStore + private val cacheStore: CacheStore, + @Named("enablePolling") private val enablePolling: Boolean, + private val clock: Clock, ) { private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob()) - private val _currencyState = MutableStateFlow(CurrencyState()) val currencyState: StateFlow = _currencyState.asStateFlow() - private var lastSuccessfulRefresh: Date? = null + @Volatile private var isRefreshing = false - private val pollingFlow: Flow + private val fxRatePollingFlow: Flow get() = flow { while (currentCoroutineContext().isActive) { emit(Unit) @@ -58,14 +64,16 @@ class CurrencyRepo @Inject constructor( }.flowOn(bgDispatcher) init { - startPolling() + if (enablePolling) { + startPolling() + } observeStaleData() collectCachedData() } private fun startPolling() { repoScope.launch { - pollingFlow.collect { + fxRatePollingFlow.collect { refresh() } } @@ -73,29 +81,38 @@ class CurrencyRepo @Inject constructor( private fun observeStaleData() { repoScope.launch { - currencyState.map { it.hasStaleData }.distinctUntilChanged().collect { isStale -> - if (isStale) { - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = "Rates currently unavailable", - description = "An error has occurred. Please try again later." - ) + currencyState + .map { it.hasStaleData } + .distinctUntilChanged() + .collect { isStale -> + if (isStale) { + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = "Rates currently unavailable", + description = "An error has occurred. Please try again later." + ) + } } - } } } private fun collectCachedData() { repoScope.launch { - combine(settingsStore.data, cacheStore.data) { settings, cachedData -> + combine( + settingsStore.data.distinctUntilChanged(), + cacheStore.data.distinctUntilChanged() + ) { settings, cachedData -> + val selectedRate = cachedData.cachedRates.firstOrNull { rate -> + rate.quote == settings.selectedCurrency + } _currencyState.value.copy( rates = cachedData.cachedRates, selectedCurrency = settings.selectedCurrency, displayUnit = settings.displayUnit, primaryDisplay = settings.primaryDisplay, - currencySymbol = cachedData.cachedRates.firstOrNull { rate -> - rate.quote == settings.selectedCurrency - }?.currencySymbol ?: "$" + currencySymbol = selectedRate?.currencySymbol ?: "$", + error = null, + hasStaleData = false ) }.collect { newState -> _currencyState.update { newState } @@ -116,19 +133,18 @@ class CurrencyRepo @Inject constructor( _currencyState.update { it.copy( error = null, - hasStaleData = false + hasStaleData = false, + lastSuccessfulRefresh = clock.now().toEpochMilliseconds(), ) } - lastSuccessfulRefresh = Date() Logger.debug("Currency rates refreshed successfully", context = TAG) } catch (e: Exception) { - _currencyState.update { it.copy(error = e) } Logger.error("Currency rates refresh failed", e, context = TAG) + _currencyState.update { it.copy(error = e) } - lastSuccessfulRefresh?.let { last -> - _currencyState.update { - it.copy(hasStaleData = Date().time - last.time > Env.fxRateStaleThreshold) - } + _currencyState.value.lastSuccessfulRefresh?.let { lastUpdatedAt -> + val isStale = clock.now().toEpochMilliseconds() - lastUpdatedAt > Env.fxRateStaleThreshold + _currencyState.update { it.copy(hasStaleData = isStale) } } } finally { isRefreshing = false @@ -136,9 +152,13 @@ class CurrencyRepo @Inject constructor( } suspend fun togglePrimaryDisplay() = withContext(bgDispatcher) { - currencyState.value.primaryDisplay.let { - val newDisplay = if (it == PrimaryDisplay.BITCOIN) PrimaryDisplay.FIAT else PrimaryDisplay.BITCOIN - settingsStore.update { it.copy(primaryDisplay = newDisplay) } + settingsStore.update { settings -> + val newDisplay = if (settings.primaryDisplay == PrimaryDisplay.BITCOIN) { + PrimaryDisplay.FIAT + } else { + PrimaryDisplay.BITCOIN + } + settings.copy(primaryDisplay = newDisplay) } } @@ -156,24 +176,74 @@ class CurrencyRepo @Inject constructor( } fun getCurrencySymbol(): String { - val currentState = currencyState.value - return currentState.rates.firstOrNull { it.quote == currentState.selectedCurrency }?.currencySymbol ?: "" + return _currencyState.value.currencySymbol } - // Conversion helpers - fun convertSatsToFiat(sats: Long, currency: String? = null): ConvertedAmount? { - val targetCurrency = currency ?: currencyState.value.selectedCurrency - val rate = currencyService.getCurrentRate(targetCurrency, currencyState.value.rates) - return rate?.let { currencyService.convert(sats = sats, rate = it) } + fun getCurrentRate(currency: String): FxRate? { + return _currencyState.value.rates.firstOrNull { it.quote == currency } + } + + fun convertSatsToFiat( + sats: Long, + currency: String? = null, + ): Result = runCatching { + val targetCurrency = currency ?: _currencyState.value.selectedCurrency + val rate = getCurrentRate(targetCurrency) ?: return Result.failure( + IllegalStateException( + "Rate not found for currency: $targetCurrency. Available currencies: ${ + _currencyState.value.rates.joinToString { it.quote } + }" + ) + ) + + val btcAmount = BigDecimal(sats).divide(BigDecimal(SATS_IN_BTC), BTC_SCALE, RoundingMode.HALF_UP) + val value = btcAmount.multiply(BigDecimal.valueOf(rate.rate)) + val formatted = value.formatCurrency() ?: return Result.failure( + IllegalStateException( + "Failed to format value: $value for currency: $targetCurrency" + ) + ) + + ConvertedAmount( + value = value, + formatted = formatted, + symbol = rate.currencySymbol, + currency = rate.quote, + flag = rate.currencyFlag, + sats = sats, + ) + } + + fun convertFiatToSats( + fiatValue: BigDecimal, + currency: String? = null, + ): Result = runCatching { + val targetCurrency = currency ?: _currencyState.value.selectedCurrency + val rate = getCurrentRate(targetCurrency) ?: throw IllegalStateException( + "Rate not found for currency: $targetCurrency. Available currencies: ${ + _currencyState.value.rates.joinToString { it.quote } + }" + ) + + val btcAmount = fiatValue.divide(BigDecimal.valueOf(rate.rate), BTC_SCALE, RoundingMode.HALF_UP) + val satsDecimal = btcAmount.multiply(BigDecimal(SATS_IN_BTC)) + val roundedSats = satsDecimal.setScale(0, RoundingMode.HALF_UP) + roundedSats.toLong().toULong() } - fun convertFiatToSats(fiatAmount: Double, currency: String? = null): Long { - val sourceCurrency = currency ?: currencyState.value.selectedCurrency - return currencyService.convertFiatToSats(fiatAmount, sourceCurrency, currencyState.value.rates) + fun convertFiatToSats( + fiatAmount: Double, + currency: String? + ): Result { + return convertFiatToSats( + fiatValue = BigDecimal.valueOf(fiatAmount), + currency = currency + ) } companion object { private const val TAG = "CurrencyRepo" + private const val BTC_SCALE = 8 } } @@ -185,4 +255,5 @@ data class CurrencyState( val currencySymbol: String = "$", val displayUnit: BitcoinDisplayUnit = BitcoinDisplayUnit.MODERN, val primaryDisplay: PrimaryDisplay = PrimaryDisplay.BITCOIN, + val lastSuccessfulRefresh: Long? = null ) diff --git a/app/src/main/java/to/bitkit/services/CurrencyService.kt b/app/src/main/java/to/bitkit/services/CurrencyService.kt index ed31c8b2d..463db9bd6 100644 --- a/app/src/main/java/to/bitkit/services/CurrencyService.kt +++ b/app/src/main/java/to/bitkit/services/CurrencyService.kt @@ -3,24 +3,16 @@ package to.bitkit.services import kotlinx.coroutines.delay import to.bitkit.async.ServiceQueue import to.bitkit.data.BlocktankHttpClient -import to.bitkit.models.ConvertedAmount import to.bitkit.models.FxRate -import to.bitkit.models.SATS_IN_BTC -import to.bitkit.ui.utils.formatCurrency import to.bitkit.utils.AppError -import java.math.BigDecimal -import java.math.RoundingMode import javax.inject.Inject import javax.inject.Singleton import kotlin.math.pow -import kotlin.math.roundToLong @Singleton -class CurrencyService @Inject constructor( //TODO REPLACE DIRECT ACCESS WITH CurrencyRepo +class CurrencyService @Inject constructor( private val blocktankHttpClient: BlocktankHttpClient, ) { - private var cachedRates: List? = null - private val maxRetries = 3 suspend fun fetchLatestRates(): List { @@ -30,10 +22,6 @@ class CurrencyService @Inject constructor( //TODO REPLACE DIRECT ACCESS WITH Cur try { val response = ServiceQueue.FOREX.background { blocktankHttpClient.fetchLatestRates() } val rates = response.tickers - - // TODO Cache to disk - cachedRates = rates - return rates } catch (e: Exception) { lastError = e @@ -47,62 +35,6 @@ class CurrencyService @Inject constructor( //TODO REPLACE DIRECT ACCESS WITH Cur throw lastError ?: CurrencyError.Unknown } - - fun loadCachedRates(): List? { - // TODO load from disk - return cachedRates - } - - fun convert(sats: Long, rate: FxRate): ConvertedAmount? { - val btcAmount = BigDecimal(sats).divide(BigDecimal(SATS_IN_BTC)) - val value: BigDecimal = btcAmount.multiply(BigDecimal.valueOf(rate.rate)) - - val formatted = value.formatCurrency() ?: return null - - return ConvertedAmount( - value = value, - formatted = formatted, - symbol = rate.currencySymbol, - currency = rate.quote, - flag = rate.currencyFlag, - sats = sats, - ) - } - - suspend fun convertSatsToFiat(satsAmount: Long, currency: String): Double { - val rates = cachedRates ?: fetchLatestRates() - val rate = getCurrentRate(currency, rates) ?: return 0.0 - - return convert(satsAmount.toLong(), rate)?.value?.toDouble() ?: 0.0 - } - - fun convertFiatToSats(fiatValue: BigDecimal, rate: FxRate): ULong { - val btcAmount = fiatValue.divide(BigDecimal.valueOf(rate.rate), 8, RoundingMode.HALF_UP) - val satsDecimal = btcAmount.multiply(BigDecimal(SATS_IN_BTC)) - - val roundedNumber = satsDecimal.setScale(0, RoundingMode.HALF_UP) - - return roundedNumber.toLong().toULong() - } - - fun convertFiatToSats(fiatAmount: Double, currency: String, rates: List): Long { - val rate = getCurrentRate(currency, rates) ?: return 0 - - // Convert the fiat amount to BTC, then to sats - val btc = fiatAmount / rate.rate - val sats = (btc * SATS_IN_BTC).roundToLong() - - return sats - } - - suspend fun convertFiatToSats(fiatAmount: Double, currency: String): Long { - val rates = cachedRates ?: fetchLatestRates() - return convertFiatToSats(fiatAmount, currency, rates) - } - - fun getCurrentRate(currency: String, rates: List): FxRate? { - return rates.firstOrNull { it.quote == currency } - } } sealed class CurrencyError(message: String) : AppError(message) { 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 7e71fffb3..faf16a09a 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 @@ -163,7 +163,7 @@ class HomeViewModel @Inject constructor( } private fun satsToUsd(sats: ULong): BigDecimal? { - val converted = currencyRepo.convertSatsToFiat(sats = sats.toLong(), currency = "USD") + val converted = currencyRepo.convertSatsToFiat(sats = sats.toLong(), currency = "USD").getOrNull() return converted?.value } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index b5fdcbc12..829ccfd1f 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -41,10 +41,10 @@ import to.bitkit.models.Suggestion import to.bitkit.models.Toast import to.bitkit.models.toActivityFilter import to.bitkit.models.toTxType +import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.WalletRepo import to.bitkit.services.CoreService -import to.bitkit.services.CurrencyService import to.bitkit.services.LdkNodeEventBus import to.bitkit.services.ScannerService import to.bitkit.services.hasLightingParam @@ -58,6 +58,7 @@ import uniffi.bitkitcore.LightningInvoice import uniffi.bitkitcore.OnChainInvoice import uniffi.bitkitcore.PaymentType import uniffi.bitkitcore.Scanner +import java.math.BigDecimal import javax.inject.Inject private const val SEND_AMOUNT_WARNING_THRESHOLD = 100.0 @@ -73,7 +74,7 @@ class AppViewModel @Inject constructor( private val coreService: CoreService, private val ldkNodeEventBus: LdkNodeEventBus, private val settingsStore: SettingsStore, - private val currencyService: CurrencyService, + private val currencyRepo: CurrencyRepo, ) : ViewModel() { var splashVisible by mutableStateOf(true) private set @@ -520,9 +521,10 @@ class AppViewModel @Inject constructor( return false } - val quickPayAmountSats = currencyService.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD") + val quickPayAmountSats = + currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD").getOrNull() ?: return false - if (amountSats <= quickPayAmountSats.toULong()) { + if (amountSats <= quickPayAmountSats) { Logger.info("Using QuickPay: $amountSats sats <= $quickPayAmountSats sats threshold") if (isMainScanner) { showSheet(BottomSheetType.Send(SendRoute.QuickPay(invoice, amountSats.toLong()))) @@ -560,8 +562,8 @@ class AppViewModel @Inject constructor( val settings = settingsStore.data.first() if (!settings.enableSendAmountWarning || _sendUiState.value.showAmountWarningDialog) return false - val amountInUsd = currencyService.convertSatsToFiat(amountSats.toLong(), "USD") - if (amountInUsd <= SEND_AMOUNT_WARNING_THRESHOLD) return false + val amountInUsd = currencyRepo.convertSatsToFiat(amountSats.toLong(), "USD").getOrNull() ?: return false + if (amountInUsd.value <= BigDecimal(SEND_AMOUNT_WARNING_THRESHOLD)) return false Logger.debug("Showing send amount warning for $amountSats sats = $$amountInUsd USD") @@ -673,7 +675,8 @@ class AppViewModel @Inject constructor( bolt11: String, amount: ULong? = null, ): Result { - val hash = lightningService.payInvoice(bolt11 = bolt11, sats = amount).getOrNull() // TODO HANDLE FAILURE IN OTHER PR + val hash = + lightningService.payInvoice(bolt11 = bolt11, sats = amount).getOrNull() // TODO HANDLE FAILURE IN OTHER PR // Wait until matching payment event is received val result = ldkNodeEventBus.events.watchUntil { event -> diff --git a/app/src/main/java/to/bitkit/viewmodels/BlocktankViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/BlocktankViewModel.kt index 8a6e3a714..1a772e4fa 100644 --- a/app/src/main/java/to/bitkit/viewmodels/BlocktankViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/BlocktankViewModel.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.launch import to.bitkit.di.BgDispatcher import to.bitkit.env.Env import to.bitkit.ext.nowTimestamp +import to.bitkit.repositories.CurrencyRepo import to.bitkit.services.CoreService import to.bitkit.services.CurrencyService import to.bitkit.services.LightningService @@ -33,12 +34,13 @@ import javax.inject.Inject import kotlin.math.ceil import kotlin.math.min +private const val EUR_CURRENCY = "EUR" @HiltViewModel class BlocktankViewModel @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val coreService: CoreService, private val lightningService: LightningService, - private val currencyService: CurrencyService, + private val currencyRepo: CurrencyRepo, ) : ViewModel() { var orders = mutableListOf() private set @@ -199,23 +201,20 @@ class BlocktankViewModel @Inject constructor( } val maxLspBalance = info?.options?.maxChannelSizeSat ?: 0uL - // Get current fx rates - val rates = currencyService.loadCachedRates() - val eurRate = rates?.let { currencyService.getCurrentRate("EUR", it) } - if (eurRate == null) { - Logger.error("Failed to get rates for lspBalance calculation", context = "BlocktankViewModel") - throw ServiceError.CurrencyRateUnavailable - } - // Calculate thresholds in sats - val threshold1 = currencyService.convertFiatToSats(BigDecimal("225"), eurRate) - val threshold2 = currencyService.convertFiatToSats(BigDecimal("495"), eurRate) - val defaultLspBalanceSats = currencyService.convertFiatToSats(BigDecimal("450"), eurRate) + val threshold1 = currencyRepo.convertFiatToSats(BigDecimal(225), EUR_CURRENCY).getOrNull() + val threshold2 = currencyRepo.convertFiatToSats(BigDecimal(495), EUR_CURRENCY).getOrNull() + val defaultLspBalanceSats = currencyRepo.convertFiatToSats(BigDecimal(450), EUR_CURRENCY).getOrNull() Logger.debug("getDefaultLspBalance - clientBalance: $clientBalance") Logger.debug("getDefaultLspBalance - maxLspBalance: $maxLspBalance") Logger.debug("getDefaultLspBalance - defaultLspBalance: $defaultLspBalanceSats") + if (threshold1 == null || threshold2 == null || defaultLspBalanceSats == null) { + Logger.error("Failed to get rates for lspBalance calculation", context = "BlocktankViewModel") + throw ServiceError.CurrencyRateUnavailable + } + // Safely calculate lspBalance to avoid arithmetic overflow var lspBalance: ULong = 0u if (defaultLspBalanceSats > clientBalance) { diff --git a/app/src/main/java/to/bitkit/viewmodels/CurrencyViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/CurrencyViewModel.kt index ebaba6d06..17ae39f11 100644 --- a/app/src/main/java/to/bitkit/viewmodels/CurrencyViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/CurrencyViewModel.kt @@ -53,11 +53,12 @@ class CurrencyViewModel @Inject constructor( // UI Helpers fun convert(sats: Long, currency: String? = null): ConvertedAmount? { - return currencyRepo.convertSatsToFiat(sats, currency) + return currencyRepo.convertSatsToFiat(sats, currency).getOrNull() } fun convertFiatToSats(fiatAmount: Double, currency: String? = null): Long { - return currencyRepo.convertFiatToSats(fiatAmount, currency) + val uLongSats = currencyRepo.convertFiatToSats(fiatAmount, currency).getOrNull() ?: 0UL + return uLongSats.toLong() } } diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index b5b26d505..4edc7fbcb 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -20,11 +20,13 @@ import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.ChannelDetails import to.bitkit.data.SettingsStore import to.bitkit.models.TransactionSpeed +import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.LightningRepo import to.bitkit.services.CoreService import to.bitkit.services.CurrencyService import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger +import to.bitkit.utils.ServiceError import uniffi.bitkitcore.BtOrderState2 import uniffi.bitkitcore.IBtInfo import uniffi.bitkitcore.IBtOrder @@ -36,12 +38,13 @@ import kotlin.math.roundToLong const val RETRY_INTERVAL_MS = 1 * 60 * 1000L // 1 minutes in ms const val GIVE_UP_MS = 30 * 60 * 1000L // 30 minutes in ms +private const val EUR_CURRENCY = "EUR" @HiltViewModel class TransferViewModel @Inject constructor( private val lightningRepo: LightningRepo, private val coreService: CoreService, - private val currencyService: CurrencyService, + private val currencyRepo: CurrencyRepo, private val settingsStore: SettingsStore, ) : ViewModel() { private val _spendingUiState = MutableStateFlow(TransferToSpendingUiState()) @@ -207,22 +210,21 @@ class TransferViewModel @Inject constructor( } private fun getDefaultLspBalance(clientBalanceSat: ULong, maxLspBalance: ULong): ULong { - val rates = currencyService.loadCachedRates() - val eurRate = rates?.let { currencyService.getCurrentRate("EUR", it) } - if (eurRate == null) { - Logger.error("Failed to get rates for getDefaultLspBalance", context = "TransferViewModel") - return 0u - } // Calculate thresholds in sats - val threshold1 = currencyService.convertFiatToSats(BigDecimal("225"), eurRate) - val threshold2 = currencyService.convertFiatToSats(BigDecimal("495"), eurRate) - val defaultLspBalanceSats = currencyService.convertFiatToSats(BigDecimal("450"), eurRate) + val threshold1 = currencyRepo.convertFiatToSats(BigDecimal(225), EUR_CURRENCY).getOrNull() + val threshold2 = currencyRepo.convertFiatToSats(BigDecimal(495), EUR_CURRENCY).getOrNull() + val defaultLspBalanceSats = currencyRepo.convertFiatToSats(BigDecimal(450), EUR_CURRENCY).getOrNull() Logger.debug("getDefaultLspBalance - clientBalanceSat: $clientBalanceSat") Logger.debug("getDefaultLspBalance - maxLspBalance: $maxLspBalance") Logger.debug("getDefaultLspBalance - defaultLspBalanceSats: $defaultLspBalanceSats") + if (threshold1 == null || threshold2 == null || defaultLspBalanceSats == null) { + Logger.error("Failed to get rates for lspBalance calculation", context = "TransferViewModel") + throw ServiceError.CurrencyRateUnavailable + } + // Safely calculate lspBalance to avoid arithmetic overflow var lspBalance: ULong = 0u if (defaultLspBalanceSats > clientBalanceSat) { diff --git a/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt new file mode 100644 index 000000000..2faadc35b --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt @@ -0,0 +1,200 @@ +package to.bitkit.repositories + +import app.cash.turbine.test +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.take +import kotlinx.datetime.Clock +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.mockito.kotlin.wheneverBlocking +import to.bitkit.data.AppCacheData +import to.bitkit.data.CacheStore +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.models.BitcoinDisplayUnit +import to.bitkit.models.FxRate +import to.bitkit.models.PrimaryDisplay +import to.bitkit.services.CurrencyService +import to.bitkit.test.BaseUnitTest +import to.bitkit.ui.shared.toast.ToastEventBus +import java.math.BigDecimal +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes + +class CurrencyRepoTest : BaseUnitTest() { + private val currencyService: CurrencyService = mock() + private val settingsStore: SettingsStore = mock() + private val cacheStore: CacheStore = mock() + private val toastEventBus: ToastEventBus = mock() + private val clock: Clock = mock() + + private lateinit var sut: CurrencyRepo + + private val testRates = listOf( + FxRate( + symbol = "BTCUSD", + lastPrice = "50000.00", + base = "BTC", + baseName = "Bitcoin", + quote = "USD", + quoteName = "US Dollar", + currencySymbol = "$", + currencyFlag = "πŸ‡ΊπŸ‡Έ", + lastUpdatedAt = System.currentTimeMillis() + ), + FxRate( + symbol = "BTCEUR", + lastPrice = "45000.00", + base = "BTC", + baseName = "Bitcoin", + quote = "EUR", + quoteName = "Euro", + currencySymbol = "€", + currencyFlag = "πŸ‡ͺπŸ‡Ί", + lastUpdatedAt = System.currentTimeMillis() + ) + ) + + @Before + fun setUp() { + // Set up default mocks + whenever(settingsStore.data).thenReturn(flowOf(SettingsData())) + whenever(cacheStore.data).thenReturn(flowOf(AppCacheData())) + } + + private fun createSut(): CurrencyRepo { + return CurrencyRepo( + bgDispatcher = testDispatcher, + currencyService = currencyService, + settingsStore = settingsStore, + cacheStore = cacheStore, + enablePolling = false, + clock = clock + ) + } + + @Test + fun `initial state should have default values`() = test { + sut = createSut() + + // Use timeout to prevent hanging + sut.currencyState.test(timeout = 1000.milliseconds) { + val initialState = awaitItem() + assertEquals(emptyList(), initialState.rates) + assertEquals("USD", initialState.selectedCurrency) + assertEquals("$", initialState.currencySymbol) + assertEquals(BitcoinDisplayUnit.MODERN, initialState.displayUnit) + assertEquals(PrimaryDisplay.BITCOIN, initialState.primaryDisplay) + assertFalse(initialState.hasStaleData) + assertNull(initialState.error) + } + } + + @Test + fun `convertSatsToFiat should handle rate properties correctly`() = test { + whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(cachedRates = testRates))) + whenever(settingsStore.data).thenReturn(flowOf(SettingsData(selectedCurrency = "USD"))) + + sut = createSut() + val result = sut.convertSatsToFiat(100_000L) // 100k sats = 0.001 BTC + + // Wait for initial state to be set up, then test conversion + sut.currencyState.take(1).test(timeout = 1000.milliseconds) { + awaitItem() // Wait for state to be initialized + + assertTrue(result.isSuccess) + val converted = result.getOrThrow() + assertEquals(BigDecimal("50.000000000"), converted.value) // 0.001 * 50000 + assertEquals("50.00", converted.formatted) + assertEquals("$", converted.symbol) + assertEquals("USD", converted.currency) + assertEquals("πŸ‡ΊπŸ‡Έ", converted.flag) + assertEquals(100_000L, converted.sats) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `convertFiatToSats should use correct rate and precision`() = test { + whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(cachedRates = testRates))) + whenever(settingsStore.data).thenReturn(flowOf(SettingsData(selectedCurrency = "EUR"))) + + sut = createSut() + + sut.currencyState.take(1).test(timeout = 1000.milliseconds) { + awaitItem() // Wait for state to be initialized + + val result = sut.convertFiatToSats(BigDecimal("45.00")) // 45 EUR / 45000 = 0.001 BTC + assertTrue(result.isSuccess) + assertEquals(100_000uL, result.getOrThrow()) // 0.001 BTC in sats + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `getCurrentRate should match by quote currency`() = test { + whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(cachedRates = testRates))) + whenever(settingsStore.data).thenReturn(flowOf(SettingsData(selectedCurrency = "EUR"))) + + sut = createSut() + val rate = sut.getCurrentRate("EUR") + + sut.currencyState.take(1).test(timeout = 1000.milliseconds) { + awaitItem() // Wait for state to be initialized + + assertEquals(testRates[1], rate) + assertEquals(45000.0, rate?.rate) + assertEquals("€", rate?.currencySymbol) + assertEquals("πŸ‡ͺπŸ‡Ί", rate?.currencyFlag) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `state should update when settings change`() = test { + val testSettings = SettingsData( + selectedCurrency = "EUR", + displayUnit = BitcoinDisplayUnit.CLASSIC, + primaryDisplay = PrimaryDisplay.FIAT + ) + + whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(cachedRates = testRates))) + whenever(settingsStore.data).thenReturn(flowOf(testSettings)) + + sut = createSut() + + sut.currencyState.test(timeout = 1000.milliseconds) { + val updatedState = awaitItem() + assertEquals("EUR", updatedState.selectedCurrency) + assertEquals("€", updatedState.currencySymbol) + assertEquals(BitcoinDisplayUnit.CLASSIC, updatedState.displayUnit) + assertEquals(PrimaryDisplay.FIAT, updatedState.primaryDisplay) + } + } + + @Test + fun `should detect stale data based on lastUpdatedAt`() = test { + whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(cachedRates = testRates))) + whenever(settingsStore.data).thenReturn(flowOf(SettingsData(selectedCurrency = "USD"))) + + sut = createSut() + whenever(clock.now()).thenReturn(Clock.System.now().minus(10.minutes)) + sut.triggerRefresh() + + wheneverBlocking { currencyService.fetchLatestRates() }.thenThrow(RuntimeException("API error")) + whenever(clock.now()).thenReturn(Clock.System.now()) + sut.triggerRefresh() + + sut.currencyState.test(timeout = 2000.milliseconds) { + val staleState = awaitItem() + assertTrue(staleState.hasStaleData) + assertEquals(testRates, staleState.rates) + } + } +}