diff --git a/lib/bloc/assets_overview/bloc/asset_overview_bloc.dart b/lib/bloc/assets_overview/bloc/asset_overview_bloc.dart index 2332171718..48f942c1cc 100644 --- a/lib/bloc/assets_overview/bloc/asset_overview_bloc.dart +++ b/lib/bloc/assets_overview/bloc/asset_overview_bloc.dart @@ -94,10 +94,17 @@ class AssetOverviewBloc extends Bloc { return; } - await _sdk.waitForEnabledCoinsToPassThreshold(event.coins); + final supportedCoins = await event.coins.filterSupportedCoins(); + if (supportedCoins.isEmpty) { + _log.warning('No supported coins to load portfolio overview for'); + return; + } + + await _sdk.waitForEnabledCoinsToPassThreshold(supportedCoins); - final activeCoins = await event.coins.removeInactiveCoins(_sdk); + final activeCoins = await supportedCoins.removeInactiveCoins(_sdk); if (activeCoins.isEmpty) { + _log.warning('No active coins to load portfolio overview for'); return; } @@ -136,7 +143,7 @@ class AssetOverviewBloc extends Bloc { emit( PortfolioAssetsOverviewLoadSuccess( - selectedAssetIds: event.coins.map((coin) => coin.id.id).toList(), + selectedAssetIds: activeCoins.map((coin) => coin.id.id).toList(), assetPortionPercentages: assetPortionPercentages, totalInvestment: totalInvestment, totalValue: FiatValue.usd(profitAmount), diff --git a/lib/bloc/cex_market_data/common/update_frequency_backoff_strategy.dart b/lib/bloc/cex_market_data/common/update_frequency_backoff_strategy.dart new file mode 100644 index 0000000000..c345e38455 --- /dev/null +++ b/lib/bloc/cex_market_data/common/update_frequency_backoff_strategy.dart @@ -0,0 +1,76 @@ +import 'dart:math' as math; + +/// A strategy for implementing exponential backoff with paired intervals. +/// The pattern is: 1min, 1min, 2min, 2min, 4min, 4min, 8min, 8min, etc. +/// This reduces API calls while still providing reasonable update frequency. +class UpdateFrequencyBackoffStrategy { + UpdateFrequencyBackoffStrategy({ + this.baseInterval = const Duration(minutes: 1), + this.maxInterval = const Duration(hours: 1), + }); + + /// The base interval for the first attempts (default: 2 minutes) + final Duration baseInterval; + + /// The maximum interval to backoff to (default: 1 hour) + final Duration maxInterval; + + int _attemptCount = 0; + + /// Reset the backoff strategy to start from the beginning + void reset() { + _attemptCount = 0; + } + + /// Get the current attempt count + int get attemptCount => _attemptCount; + + /// Get the next interval duration and increment the attempt count + Duration getNextInterval() { + final interval = getCurrentInterval(); + _attemptCount++; + return interval; + } + + /// Get the current interval duration without incrementing the attempt count + Duration getCurrentInterval() { + // Calculate which "pair" we're in (0, 1, 2, 3, ...) + // Each pair has 2 attempts with the same interval + final pairIndex = _attemptCount ~/ 2; + + // Calculate the multiplier: 2^pairIndex + final multiplier = math.pow(2, pairIndex).toInt(); + + // Calculate the interval + final intervalMs = baseInterval.inMilliseconds * multiplier; + + // Cap at maximum interval + final cappedIntervalMs = math.min(intervalMs, maxInterval.inMilliseconds); + + return Duration(milliseconds: cappedIntervalMs); + } + + /// Check if we should update the cache on the current attempt + /// Returns true for cache update attempts, false for cache-only reads + bool shouldUpdateCache() { + // Update cache on every attempt for now, but this could be modified + // to only update on certain intervals if needed + return true; + } + + /// Get a preview of the next N intervals without affecting the state + List previewNextIntervals(int count) { + final currentAttempt = _attemptCount; + final intervals = []; + + for (int i = 0; i < count; i++) { + final pairIndex = (currentAttempt + i) ~/ 2; + final multiplier = math.pow(2, pairIndex).toInt(); + final intervalMs = baseInterval.inMilliseconds * multiplier; + final cappedIntervalMs = math.min(intervalMs, maxInterval.inMilliseconds); + intervals.add(Duration(milliseconds: cappedIntervalMs)); + } + + return intervals; + } +} \ No newline at end of file diff --git a/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart index e16efd10b6..e9f3747cf2 100644 --- a/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart +++ b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart @@ -2,19 +2,17 @@ import 'dart:async'; import 'package:bloc/bloc.dart'; import 'package:bloc_concurrency/bloc_concurrency.dart'; -import 'package:decimal/decimal.dart'; import 'package:equatable/equatable.dart'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:logging/logging.dart'; -import 'package:rational/rational.dart'; import 'package:web_dex/bloc/cex_market_data/charts.dart'; +import 'package:web_dex/bloc/cex_market_data/common/update_frequency_backoff_strategy.dart'; import 'package:web_dex/bloc/cex_market_data/portfolio_growth/portfolio_growth_repository.dart'; import 'package:web_dex/bloc/cex_market_data/sdk_auth_activation_extension.dart'; import 'package:web_dex/bloc/coins_bloc/asset_coin_extension.dart'; import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/text_error.dart'; -import 'package:web_dex/shared/utils/extensions/legacy_coin_migration_extensions.dart'; part 'portfolio_growth_event.dart'; part 'portfolio_growth_state.dart'; @@ -24,8 +22,10 @@ class PortfolioGrowthBloc PortfolioGrowthBloc({ required PortfolioGrowthRepository portfolioGrowthRepository, required KomodoDefiSdk sdk, + UpdateFrequencyBackoffStrategy? backoffStrategy, }) : _sdk = sdk, _portfolioGrowthRepository = portfolioGrowthRepository, + _backoffStrategy = backoffStrategy ?? UpdateFrequencyBackoffStrategy(), super(const PortfolioGrowthInitial()) { // Use the restartable transformer for period change events to avoid // overlapping events if the user rapidly changes the period (i.e. faster @@ -44,6 +44,7 @@ class PortfolioGrowthBloc final PortfolioGrowthRepository _portfolioGrowthRepository; final KomodoDefiSdk _sdk; final _log = Logger('PortfolioGrowthBloc'); + final UpdateFrequencyBackoffStrategy _backoffStrategy; void _onClearPortfolioGrowth( PortfolioGrowthClearRequested event, @@ -56,12 +57,13 @@ class PortfolioGrowthBloc PortfolioGrowthPeriodChanged event, Emitter emit, ) { + final coins = event.coins.withoutTestCoins(); final ( int totalCoins, int coinsWithKnownBalance, int coinsWithKnownBalanceAndFiat, ) = _calculateCoinProgressCounters( - event.coins, + coins, ); final currentState = state; if (currentState is PortfolioGrowthChartLoadSuccess) { @@ -104,10 +106,9 @@ class PortfolioGrowthBloc add( PortfolioGrowthLoadRequested( - coins: event.coins, + coins: coins, selectedPeriod: event.selectedPeriod, fiatCoinId: 'USDT', - updateFrequency: event.updateFrequency, walletId: event.walletId, ), ); @@ -118,16 +119,22 @@ class PortfolioGrowthBloc Emitter emit, ) async { try { - final List coins = await _removeUnsupportedCoins(event); + final List coins = await event.coins.filterSupportedCoins( + (coin) => _portfolioGrowthRepository.isCoinChartSupported( + coin.id, + event.fiatCoinId, + ), + ); // Charts for individual coins (coin details) are parsed here as well, // and should be hidden if not supported. - if (coins.isEmpty && event.coins.length <= 1) { + final filteredEventCoins = event.coins.withoutTestCoins(); + if (coins.isEmpty && filteredEventCoins.length <= 1) { final ( int totalCoins, int coinsWithKnownBalance, int coinsWithKnownBalanceAndFiat, ) = _calculateCoinProgressCounters( - event.coins, + filteredEventCoins, ); return emit( PortfolioGrowthChartUnsupported( @@ -139,9 +146,8 @@ class PortfolioGrowthBloc ); } - final initialActiveCoins = await coins.removeInactiveCoins(_sdk); await _loadChart( - initialActiveCoins, + filteredEventCoins, event, useCache: true, ).then(emit.call).catchError((Object error, StackTrace stackTrace) { @@ -154,79 +160,31 @@ class PortfolioGrowthBloc // In case most coins are activating on wallet startup, wait for at least // 50% of the coins to be enabled before attempting to load the uncached // chart. - await _sdk.waitForEnabledCoinsToPassThreshold(event.coins); + await _sdk.waitForEnabledCoinsToPassThreshold(filteredEventCoins); // Only remove inactivate/activating coins after an attempt to load the // cached chart, as the cached chart may contain inactive coins. - final activeCoins = await coins.removeInactiveCoins(_sdk); - if (activeCoins.isNotEmpty) { - await _loadChart( - activeCoins, - event, - useCache: false, - ).then(emit.call).catchError((Object error, StackTrace stackTrace) { - _log.shout('Failed to load chart', error, stackTrace); - // Don't emit an error state here. If cached and uncached attempts - // both fail, the periodic refresh attempts should recovery - // at the cost of a longer first loading time. - }); - } + await _loadChart( + filteredEventCoins, + event, + useCache: false, + ).then(emit.call).catchError((Object error, StackTrace stackTrace) { + _log.shout('Failed to load chart', error, stackTrace); + // Don't emit an error state here. If cached and uncached attempts + // both fail, the periodic refresh attempts should recovery + // at the cost of a longer first loading time. + }); } catch (error, stackTrace) { _log.shout('Failed to load portfolio growth', error, stackTrace); // Don't emit an error state here, as the periodic refresh attempts should // recover at the cost of a longer first loading time. } - final periodicUpdate = Stream.periodic( - event.updateFrequency, - ).asyncMap((_) async => _fetchPortfolioGrowthChart(event)); - - // Use await for here to allow for the async update handler. The previous - // implementation awaited the emit.forEach to ensure that cancelling the - // event handler with transformers would stop the previous periodic updates. - await for (final data in periodicUpdate) { - try { - emit( - await _handlePortfolioGrowthUpdate( - data, - event.selectedPeriod, - event.coins, - ), - ); - } catch (error, stackTrace) { - _log.shout('Failed to load portfolio growth', error, stackTrace); - final ( - int totalCoins, - int coinsWithKnownBalance, - int coinsWithKnownBalanceAndFiat, - ) = _calculateCoinProgressCounters( - event.coins, - ); - emit( - GrowthChartLoadFailure( - error: TextError(error: 'Failed to load portfolio growth'), - selectedPeriod: event.selectedPeriod, - totalCoins: totalCoins, - coinsWithKnownBalance: coinsWithKnownBalance, - coinsWithKnownBalanceAndFiat: coinsWithKnownBalanceAndFiat, - ), - ); - } - } - } + // Reset backoff strategy for new load request + _backoffStrategy.reset(); - Future> _removeUnsupportedCoins( - PortfolioGrowthLoadRequested event, - ) async { - final List coins = List.from(event.coins); - for (final coin in event.coins) { - final isCoinSupported = await _portfolioGrowthRepository - .isCoinChartSupported(coin.id, event.fiatCoinId); - if (!isCoinSupported) { - coins.remove(coin); - } - } - return coins; + // Create periodic update stream with dynamic intervals + await _runPeriodicUpdates(event, emit); } Future _loadChart( @@ -234,8 +192,9 @@ class PortfolioGrowthBloc PortfolioGrowthLoadRequested event, { required bool useCache, }) async { + final activeCoins = await coins.removeInactiveCoins(_sdk); final chart = await _portfolioGrowthRepository.getPortfolioGrowthChart( - coins, + activeCoins, fiatCoinId: event.fiatCoinId, walletId: event.walletId, useCache: useCache, @@ -245,16 +204,16 @@ class PortfolioGrowthBloc return state; } - final totalBalance = _calculateTotalBalance(coins); - final totalChange24h = await _calculateTotalChange24h(coins); - final percentageChange24h = await _calculatePercentageChange24h(coins); + final totalBalance = coins.totalLastKnownUsdBalance(_sdk); + final totalChange24h = await coins.totalChange24h(_sdk); + final percentageChange24h = await coins.percentageChange24h(_sdk); final ( int totalCoins, int coinsWithKnownBalance, int coinsWithKnownBalanceAndFiat, ) = _calculateCoinProgressCounters( - event.coins, + coins, ); return PortfolioGrowthChartLoadSuccess( @@ -271,22 +230,28 @@ class PortfolioGrowthBloc ); } - Future _fetchPortfolioGrowthChart( + Future<(ChartData, List)> _fetchPortfolioGrowthChart( PortfolioGrowthLoadRequested event, ) async { // Do not let transaction loading exceptions stop the periodic updates try { - final supportedCoins = await _removeUnsupportedCoins(event); + final supportedCoins = await event.coins.filterSupportedCoins( + (coin) => _portfolioGrowthRepository.isCoinChartSupported( + coin.id, + event.fiatCoinId, + ), + ); final coins = await supportedCoins.removeInactiveCoins(_sdk); - return await _portfolioGrowthRepository.getPortfolioGrowthChart( + final chart = await _portfolioGrowthRepository.getPortfolioGrowthChart( coins, fiatCoinId: event.fiatCoinId, walletId: event.walletId, useCache: false, ); + return (chart, coins); } catch (error, stackTrace) { _log.shout('Empty growth chart on periodic update', error, stackTrace); - return ChartData.empty(); + return (ChartData.empty(), []); } } @@ -300,9 +265,9 @@ class PortfolioGrowthBloc } final percentageIncrease = growthChart.percentageIncrease; - final totalBalance = _calculateTotalBalance(coins); - final totalChange24h = await _calculateTotalChange24h(coins); - final percentageChange24h = await _calculatePercentageChange24h(coins); + final totalBalance = coins.totalLastKnownUsdBalance(_sdk); + final totalChange24h = await coins.totalChange24h(_sdk); + final percentageChange24h = await coins.percentageChange24h(_sdk); final ( int totalCoins, @@ -326,52 +291,6 @@ class PortfolioGrowthBloc ); } - /// Calculate the total balance of all coins in USD - double _calculateTotalBalance(List coins) { - double total = coins.fold( - 0, - (prev, coin) => prev + (coin.lastKnownUsdBalance(_sdk) ?? 0), - ); - - // Return at least 0.01 if total is positive but very small - if (total > 0 && total < 0.01) { - return 0.01; - } - - return total; - } - - /// Calculate the total 24h change in USD value - /// TODO: look into avoiding zero default values here if no data is available - Future _calculateTotalChange24h(List coins) async { - Rational totalChange = Rational.zero; - for (final coin in coins) { - final double usdBalance = coin.lastKnownUsdBalance(_sdk) ?? 0.0; - final usdBalanceDecimal = Decimal.parse(usdBalance.toString()); - final change24h = - await _sdk.marketData.priceChange24h(coin.id) ?? Decimal.zero; - totalChange += change24h * usdBalanceDecimal / Decimal.fromInt(100); - } - return totalChange; - } - - /// Calculate the percentage change over 24h for the entire portfolio - Future _calculatePercentageChange24h(List coins) async { - final double totalBalance = _calculateTotalBalance(coins); - final Rational totalBalanceRational = Rational.parse( - totalBalance.toString(), - ); - final Rational totalChange = await _calculateTotalChange24h(coins); - - // Avoid division by zero or very small balances - if (totalBalanceRational <= Rational.fromInt(1, 100)) { - return Rational.zero; - } - - // Return the percentage change - return (totalChange / totalBalanceRational) * Rational.fromInt(100); - } - /// Calculate progress counters for balances and fiat prices /// - totalCoins: total coins being considered (input list length) /// - coinsWithKnownBalance: number of coins with a known last balance @@ -392,4 +311,54 @@ class PortfolioGrowthBloc } return (totalCoins, withBalance, withBalanceAndFiat); } + + /// Run periodic updates with exponential backoff strategy + Future _runPeriodicUpdates( + PortfolioGrowthLoadRequested event, + Emitter emit, + ) async { + while (true) { + if (isClosed || emit.isDone) { + _log.fine('Stopping portfolio growth periodic updates: bloc closed.'); + break; + } + try { + await Future.delayed(_backoffStrategy.getNextInterval()); + + if (isClosed || emit.isDone) { + _log.fine( + 'Skipping portfolio growth periodic update: bloc closed during delay.', + ); + break; + } + + final (chart, coins) = await _fetchPortfolioGrowthChart(event); + emit( + await _handlePortfolioGrowthUpdate( + chart, + event.selectedPeriod, + coins, + ), + ); + } catch (error, stackTrace) { + _log.shout('Failed to load portfolio growth', error, stackTrace); + final ( + int totalCoins, + int coinsWithKnownBalance, + int coinsWithKnownBalanceAndFiat, + ) = _calculateCoinProgressCounters( + event.coins.withoutTestCoins(), + ); + emit( + GrowthChartLoadFailure( + error: TextError(error: 'Failed to load portfolio growth'), + selectedPeriod: event.selectedPeriod, + totalCoins: totalCoins, + coinsWithKnownBalance: coinsWithKnownBalance, + coinsWithKnownBalanceAndFiat: coinsWithKnownBalanceAndFiat, + ), + ); + } + } + } } diff --git a/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_event.dart b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_event.dart index 0aa750b24a..957d1e97fb 100644 --- a/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_event.dart +++ b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_event.dart @@ -17,23 +17,15 @@ class PortfolioGrowthLoadRequested extends PortfolioGrowthEvent { required this.fiatCoinId, required this.selectedPeriod, required this.walletId, - this.updateFrequency = const Duration(minutes: 1), }); final List coins; final String fiatCoinId; final Duration selectedPeriod; final String walletId; - final Duration updateFrequency; @override - List get props => [ - coins, - fiatCoinId, - selectedPeriod, - walletId, - updateFrequency, - ]; + List get props => [coins, fiatCoinId, selectedPeriod, walletId]; } class PortfolioGrowthPeriodChanged extends PortfolioGrowthEvent { @@ -41,14 +33,12 @@ class PortfolioGrowthPeriodChanged extends PortfolioGrowthEvent { required this.selectedPeriod, required this.coins, required this.walletId, - this.updateFrequency = const Duration(minutes: 1), }); final Duration selectedPeriod; final List coins; final String walletId; - final Duration updateFrequency; @override - List get props => [selectedPeriod, coins, walletId, updateFrequency]; + List get props => [selectedPeriod, coins, walletId]; } diff --git a/lib/bloc/cex_market_data/profit_loss/profit_loss_bloc.dart b/lib/bloc/cex_market_data/profit_loss/profit_loss_bloc.dart index ad14866c6c..b382058728 100644 --- a/lib/bloc/cex_market_data/profit_loss/profit_loss_bloc.dart +++ b/lib/bloc/cex_market_data/profit_loss/profit_loss_bloc.dart @@ -7,6 +7,7 @@ import 'package:equatable/equatable.dart'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:logging/logging.dart'; import 'package:web_dex/bloc/cex_market_data/charts.dart'; +import 'package:web_dex/bloc/cex_market_data/common/update_frequency_backoff_strategy.dart'; import 'package:web_dex/bloc/cex_market_data/profit_loss/profit_loss_repository.dart'; import 'package:web_dex/bloc/cex_market_data/sdk_auth_activation_extension.dart'; import 'package:web_dex/bloc/coins_bloc/asset_coin_extension.dart'; @@ -18,8 +19,12 @@ part 'profit_loss_event.dart'; part 'profit_loss_state.dart'; class ProfitLossBloc extends Bloc { - ProfitLossBloc(this._profitLossRepository, this._sdk) - : super(const ProfitLossInitial()) { + ProfitLossBloc( + this._profitLossRepository, + this._sdk, { + UpdateFrequencyBackoffStrategy? backoffStrategy, + }) : _backoffStrategy = backoffStrategy ?? UpdateFrequencyBackoffStrategy(), + super(const ProfitLossInitial()) { // Use the restartable transformer for load events to avoid overlapping // events if the user rapidly changes the period (i.e. faster than the // previous event can complete). @@ -35,6 +40,7 @@ class ProfitLossBloc extends Bloc { final KomodoDefiSdk _sdk; final _log = Logger('ProfitLossBloc'); + final UpdateFrequencyBackoffStrategy _backoffStrategy; void _onClearPortfolioProfitLoss( ProfitLossPortfolioChartClearRequested event, @@ -48,14 +54,12 @@ class ProfitLossBloc extends Bloc { Emitter emit, ) async { try { - final supportedCoins = await _removeUnsupportedCons( - event.coins, - event.fiatCoinId, - ); + final supportedCoins = await event.coins.filterSupportedCoins(); + final filteredEventCoins = event.coins.withoutTestCoins(); final initialActiveCoins = await supportedCoins.removeInactiveCoins(_sdk); // Charts for individual coins (coin details) are parsed here as well, // and should be hidden if not supported. - if (supportedCoins.isEmpty && event.coins.length <= 1) { + if (supportedCoins.isEmpty && filteredEventCoins.length <= 1) { return emit( PortfolioProfitLossChartUnsupported( selectedPeriod: event.selectedPeriod, @@ -75,7 +79,9 @@ class ProfitLossBloc extends Bloc { }); // Fetch the un-cached version of the chart to update the cache. - await _sdk.waitForEnabledCoinsToPassThreshold(supportedCoins); + if (supportedCoins.isNotEmpty) { + await _sdk.waitForEnabledCoinsToPassThreshold(supportedCoins); + } final activeCoins = await supportedCoins.removeInactiveCoins(_sdk); if (activeCoins.isNotEmpty) { await _getProfitLossChart( @@ -96,19 +102,11 @@ class ProfitLossBloc extends Bloc { // recover at the cost of a longer first loading time. } - await emit.forEach( - Stream.periodic(event.updateFrequency).asyncMap( - (_) async => _getProfitLossChart(event, event.coins, useCache: false), - ), - onData: (ProfitLossState updatedChartState) => updatedChartState, - onError: (e, s) { - _log.shout('Failed to load portfolio profit/loss', e, s); - return ProfitLossLoadFailure( - error: TextError(error: 'Failed to load portfolio profit/loss'), - selectedPeriod: event.selectedPeriod, - ); - }, - ); + // Reset backoff strategy for new load request + _backoffStrategy.reset(); + + // Create periodic update stream with dynamic intervals + await _runPeriodicUpdates(event, emit); } Future _getProfitLossChart( @@ -121,6 +119,7 @@ class ProfitLossBloc extends Bloc { try { final filteredChart = await _getSortedProfitLossChartForCoins( event, + coins, useCache: useCache, ); final unCachedProfitIncrease = filteredChart.increase; @@ -141,19 +140,6 @@ class ProfitLossBloc extends Bloc { } } - Future> _removeUnsupportedCons( - List walletCoins, - String fiatCoinId, - ) async { - final coins = List.of(walletCoins); - for (final coin in coins) { - if (coin.isTestCoin) { - coins.remove(coin); - } - } - return coins; - } - Future _onPortfolioPeriodChanged( ProfitLossPortfolioPeriodChanged event, Emitter emit, @@ -191,7 +177,8 @@ class ProfitLossBloc extends Bloc { } Future _getSortedProfitLossChartForCoins( - ProfitLossPortfolioChartLoadRequested event, { + ProfitLossPortfolioChartLoadRequested event, + List coins, { bool useCache = true, }) async { if (!await _sdk.auth.isSignedIn()) { @@ -199,8 +186,18 @@ class ProfitLossBloc extends Bloc { return ChartData.empty(); } + final supportedCoins = await coins.filterSupportedCoins(); + if (supportedCoins.isEmpty) { + _log.warning('No supported coins to load profit/loss chart for'); + return ChartData.empty(); + } + final activeCoins = await supportedCoins.removeInactiveCoins(_sdk); + if (activeCoins.isEmpty) { + _log.warning('No active coins to load profit/loss chart for'); + return ChartData.empty(); + } final chartsList = await Future.wait( - event.coins.map((coin) async { + activeCoins.map((coin) async { // Catch any errors and return an empty chart to prevent a single coin // from breaking the entire portfolio chart. try { @@ -234,4 +231,44 @@ class ProfitLossBloc extends Bloc { chartsList.removeWhere((element) => element.isEmpty); return Charts.merge(chartsList)..sort((a, b) => a.x.compareTo(b.x)); } + + /// Run periodic updates with exponential backoff strategy + Future _runPeriodicUpdates( + ProfitLossPortfolioChartLoadRequested event, + Emitter emit, + ) async { + while (true) { + if (isClosed || emit.isDone) { + _log.fine('Stopping profit/loss periodic updates: bloc closed.'); + break; + } + try { + await Future.delayed(_backoffStrategy.getNextInterval()); + + if (isClosed || emit.isDone) { + _log.fine( + 'Skipping profit/loss periodic update: bloc closed during delay.', + ); + break; + } + + final supportedCoins = await event.coins.filterSupportedCoins(); + final activeCoins = await supportedCoins.removeInactiveCoins(_sdk); + final updatedChartState = await _getProfitLossChart( + event, + activeCoins, + useCache: false, + ); + emit(updatedChartState); + } catch (error, stackTrace) { + _log.shout('Failed to load portfolio profit/loss', error, stackTrace); + emit( + ProfitLossLoadFailure( + error: TextError(error: 'Failed to load portfolio profit/loss'), + selectedPeriod: event.selectedPeriod, + ), + ); + } + } + } } diff --git a/lib/bloc/cex_market_data/profit_loss/profit_loss_event.dart b/lib/bloc/cex_market_data/profit_loss/profit_loss_event.dart index 14ff2ff6da..15049b2f8e 100644 --- a/lib/bloc/cex_market_data/profit_loss/profit_loss_event.dart +++ b/lib/bloc/cex_market_data/profit_loss/profit_loss_event.dart @@ -17,7 +17,7 @@ class ProfitLossPortfolioChartLoadRequested extends ProfitLossEvent { required this.fiatCoinId, required this.selectedPeriod, required this.walletId, - this.updateFrequency = const Duration(minutes: 1), + this.updateFrequency = const Duration(minutes: 2), }); final List coins; @@ -39,7 +39,7 @@ class ProfitLossPortfolioChartLoadRequested extends ProfitLossEvent { class ProfitLossPortfolioPeriodChanged extends ProfitLossEvent { const ProfitLossPortfolioPeriodChanged({ required this.selectedPeriod, - this.updateFrequency = const Duration(minutes: 1), + this.updateFrequency = const Duration(minutes: 2), }); final Duration selectedPeriod; diff --git a/lib/bloc/coins_bloc/asset_coin_extension.dart b/lib/bloc/coins_bloc/asset_coin_extension.dart index 5e7b3a1766..f1eef13e7c 100644 --- a/lib/bloc/coins_bloc/asset_coin_extension.dart +++ b/lib/bloc/coins_bloc/asset_coin_extension.dart @@ -2,10 +2,12 @@ import 'package:decimal/decimal.dart'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:rational/rational.dart' show Rational; import 'package:web_dex/app_config/app_config.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/coin_type.dart'; import 'package:web_dex/shared/utils/extensions/collection_extensions.dart'; +import 'package:web_dex/shared/utils/extensions/legacy_coin_migration_extensions.dart'; extension AssetCoinExtension on Asset { Coin toCoin() { @@ -234,7 +236,51 @@ extension AssetBalanceExtension on Coin { } } -extension CoinListOps on List { +extension AssetListOps on List { + Future> removeInactiveAssets(KomodoDefiSdk sdk) async { + final activeAssets = await sdk.assets.getActivatedAssets(); + final activeAssetsMap = activeAssets.map((e) => e.id).toSet(); + + return where( + (asset) => activeAssetsMap.contains(asset.id), + ).unmodifiable().toList(); + } + + Future> removeActiveAssets(KomodoDefiSdk sdk) async { + final activeAssets = await sdk.assets.getActivatedAssets(); + final activeAssetsMap = activeAssets.map((e) => e.id).toSet(); + + return where( + (asset) => !activeAssetsMap.contains(asset.id), + ).unmodifiable().toList(); + } +} + +extension CoinSupportOps on Iterable { + /// Returns a list excluding test coins. Useful when filtering coins before + /// running portfolio calculations that assume production assets only. + List withoutTestCoins() => + where((coin) => !coin.isTestCoin).unmodifiable().toList(); + + /// Filters out unsupported coins by first removing test coins and then + /// evaluating the optional [isSupported] predicate. When the predicate is not + /// provided, only test coins are removed. + Future> filterSupportedCoins([ + Future Function(Coin coin)? isSupported, + ]) async { + final predicate = isSupported ?? _alwaysSupported; + final supportedCoins = []; + for (final coin in this) { + if (coin.isTestCoin) continue; + if (await predicate(coin)) { + supportedCoins.add(coin); + } + } + return supportedCoins.unmodifiable().toList(); + } + + static Future _alwaysSupported(Coin _) async => true; + Future> removeInactiveCoins(KomodoDefiSdk sdk) async { final activeCoins = await sdk.assets.getActivatedAssets(); final activeCoinsMap = activeCoins.map((e) => e.id).toSet(); @@ -252,24 +298,46 @@ extension CoinListOps on List { (coin) => !activeCoinsMap.contains(coin.id), ).unmodifiable().toList(); } -} -extension AssetListOps on List { - Future> removeInactiveAssets(KomodoDefiSdk sdk) async { - final activeAssets = await sdk.assets.getActivatedAssets(); - final activeAssetsMap = activeAssets.map((e) => e.id).toSet(); + double totalLastKnownUsdBalance(KomodoDefiSdk sdk) { + double total = fold( + 0.00, + (prev, coin) => prev + (coin.lastKnownUsdBalance(sdk) ?? 0), + ); - return where( - (asset) => activeAssetsMap.contains(asset.id), - ).unmodifiable().toList(); + // Return at least 0.01 if total is positive but very small + if (total > 0 && total < 0.01) { + return 0.01; + } + + return total; } - Future> removeActiveAssets(KomodoDefiSdk sdk) async { - final activeAssets = await sdk.assets.getActivatedAssets(); - final activeAssetsMap = activeAssets.map((e) => e.id).toSet(); + Future totalChange24h(KomodoDefiSdk sdk) async { + Rational totalChange = Rational.zero; + for (final coin in this) { + final double usdBalance = coin.lastKnownUsdBalance(sdk) ?? 0.0; + final usdBalanceDecimal = Decimal.parse(usdBalance.toString()); + final change24h = + await sdk.marketData.priceChange24h(coin.id) ?? Decimal.zero; + totalChange += change24h * usdBalanceDecimal / Decimal.fromInt(100); + } + return totalChange; + } - return where( - (asset) => !activeAssetsMap.contains(asset.id), - ).unmodifiable().toList(); + Future percentageChange24h(KomodoDefiSdk sdk) async { + final double totalBalance = totalLastKnownUsdBalance(sdk); + final Rational totalBalanceRational = Rational.parse( + totalBalance.toString(), + ); + final Rational totalChange = await totalChange24h(sdk); + + // Avoid division by zero or very small balances + if (totalBalanceRational <= Rational.fromInt(1, 100)) { + return Rational.zero; + } + + // Return the percentage change + return (totalChange / totalBalanceRational) * Rational.fromInt(100); } } diff --git a/lib/bloc/coins_bloc/coins_bloc.dart b/lib/bloc/coins_bloc/coins_bloc.dart index 3784a0a6e0..2dd8b73a58 100644 --- a/lib/bloc/coins_bloc/coins_bloc.dart +++ b/lib/bloc/coins_bloc/coins_bloc.dart @@ -8,6 +8,7 @@ import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:logging/logging.dart'; import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/bloc/cex_market_data/sdk_auth_activation_extension.dart'; import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; import 'package:web_dex/model/cex_price.dart'; import 'package:web_dex/model/coin.dart'; @@ -45,6 +46,7 @@ class CoinsBloc extends Bloc { StreamSubscription? _enabledCoinsSubscription; Timer? _updateBalancesTimer; Timer? _updatePricesTimer; + bool _isInitialActivationInProgress = false; @override Future close() async { @@ -60,11 +62,24 @@ class CoinsBloc extends Bloc { Emitter emit, ) async { try { - // Return early if the coin is not yet in wallet coins, meaning that - // it's not yet activated. - // TODO: update this once coin activation is fully handled by the SDK + if (_isInitialActivationInProgress) { + _log.info( + 'Skipping pubkeys request for ${event.coinId} while initial activation is in progress.', + ); + return; + } + + // Coins are added to walletCoins before activation even starts + // to show them in the UI regardless of activation state. + // If the coin is not found here, it means the auth state handler + // has not pre-populated the list with activating coins yet. final coin = state.walletCoins[event.coinId]; - if (coin == null) return; + if (coin == null) { + _log.warning( + 'Coin ${event.coinId} not found in wallet coins, cannot fetch pubkeys', + ); + return; + } // Get pubkeys from the SDK through the repo final asset = _kdfSdk.assets.available[coin.id]!; @@ -287,6 +302,7 @@ class CoinsBloc extends Bloc { CoinsSessionStarted event, Emitter emit, ) async { + _isInitialActivationInProgress = true; try { // Ensure any cached addresses/pubkeys from a previous wallet are cleared // so that UI fetches fresh pubkeys for the newly logged-in wallet. @@ -298,11 +314,20 @@ class CoinsBloc extends Bloc { // in the list at once, rather than one at a time as they are activated final coinsToActivate = currentWallet.config.activatedCoins; emit(_prePopulateListWithActivatingCoins(coinsToActivate)); - await _activateCoins(coinsToActivate, emit); - - add(CoinsBalancesRefreshed()); - add(CoinsBalanceMonitoringStarted()); + _scheduleInitialBalanceRefresh(coinsToActivate); + + final activationFuture = _activateCoins(coinsToActivate, emit); + unawaited(() async { + try { + await activationFuture; + } catch (e, s) { + _log.shout('Error during initial coin activation', e, s); + } finally { + _isInitialActivationInProgress = false; + } + }()); } catch (e, s) { + _isInitialActivationInProgress = false; _log.shout('Error on login', e, s); } } @@ -311,6 +336,7 @@ class CoinsBloc extends Bloc { CoinsSessionEnded event, Emitter emit, ) async { + _resetInitialActivationState(); add(CoinsBalanceMonitoringStopped()); emit( @@ -324,6 +350,60 @@ class CoinsBloc extends Bloc { _coinsRepo.flushCache(); } + void _scheduleInitialBalanceRefresh(Iterable coinsToActivate) { + if (isClosed) return; + + final knownCoins = _coinsRepo.getKnownCoinsMap(); + final walletCoinsForThreshold = coinsToActivate + .map((coinId) => knownCoins[coinId]) + .whereType() + .toList(); + + if (walletCoinsForThreshold.isEmpty) { + add(CoinsBalancesRefreshed()); + add(CoinsBalanceMonitoringStarted()); + return; + } + + unawaited(() async { + var triggeredByThreshold = false; + try { + triggeredByThreshold = await _kdfSdk.waitForEnabledCoinsToPassThreshold( + walletCoinsForThreshold, + threshold: 0.8, + timeout: const Duration(minutes: 1), + ); + } catch (e, s) { + _log.shout( + 'Failed while waiting for enabled coins threshold during login', + e, + s, + ); + } + + if (isClosed) { + return; + } + + if (triggeredByThreshold) { + _log.fine( + 'Initial balance refresh triggered after 80% of coins activated.', + ); + } else { + _log.fine( + 'Initial balance refresh triggered after timeout while waiting for coin activation.', + ); + } + + add(CoinsBalancesRefreshed()); + add(CoinsBalanceMonitoringStarted()); + }()); + } + + void _resetInitialActivationState() { + _isInitialActivationInProgress = false; + } + Future _activateCoins( Iterable coins, Emitter emit, diff --git a/lib/bloc/coins_bloc/coins_repo.dart b/lib/bloc/coins_bloc/coins_repo.dart index 56da3f8a08..c961cdea52 100644 --- a/lib/bloc/coins_bloc/coins_repo.dart +++ b/lib/bloc/coins_bloc/coins_repo.dart @@ -26,7 +26,6 @@ import 'package:web_dex/model/cex_price.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/kdf_auth_metadata_extension.dart'; import 'package:web_dex/model/text_error.dart'; -import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/model/withdraw_details/withdraw_details.dart'; import 'package:web_dex/services/arrr_activation/arrr_activation_service.dart'; @@ -182,29 +181,8 @@ class CoinsRepo { 'Wallet [KdfUser].wallet extension instead.', ) Future> getWalletCoins() async { - final currentUser = await _kdfSdk.auth.currentUser; - if (currentUser == null) { - return []; - } - - return currentUser.wallet.config.activatedCoins - .map((coinId) { - final assets = _kdfSdk.assets.findAssetsByConfigId(coinId); - if (assets.isEmpty) { - _log.warning('No assets found for coinId: $coinId'); - return null; - } - if (assets.length > 1) { - _log.shout( - 'Multiple assets found for coinId: $coinId (${assets.length} assets). ' - 'Selecting the first asset: ${assets.first.id.id}', - ); - } - return assets.single; - }) - .whereType() - .map(_assetToCoinWithoutAddress) - .toList(); + final walletAssets = await _kdfSdk.getWalletAssets(); + return walletAssets.map(_assetToCoinWithoutAddress).toList(); } Coin _assetToCoinWithoutAddress(Asset asset) { @@ -252,14 +230,14 @@ class CoinsRepo { /// exponential backoff for up to the specified duration. /// /// **Retry Configuration:** - /// - Default: 500ms → 1s → 2s → 4s → 8s → 10s → 10s... (30 attempts ≈ 3 minutes) + /// - Default: 500ms → 1s → 2s → 4s → 8s → 10s → 10s... (15 attempts ≈ 105 seconds) /// - Configurable via [maxRetryAttempts], [initialRetryDelay], and [maxRetryDelay] /// /// **Parameters:** /// - [assets]: List of assets to activate /// - [notifyListeners]: Whether to broadcast state changes to listeners (default: true) /// - [addToWalletMetadata]: Whether to add assets to wallet metadata (default: true) - /// - [maxRetryAttempts]: Maximum number of retry attempts (default: 30) + /// - [maxRetryAttempts]: Maximum number of retry attempts (default: 15) /// - [initialRetryDelay]: Initial delay between retries (default: 500ms) /// - [maxRetryDelay]: Maximum delay between retries (default: 10s) /// @@ -276,7 +254,7 @@ class CoinsRepo { List assets, { bool notifyListeners = true, bool addToWalletMetadata = true, - int maxRetryAttempts = 30, + int maxRetryAttempts = 15, Duration initialRetryDelay = const Duration(milliseconds: 500), Duration maxRetryDelay = const Duration(seconds: 10), }) async { @@ -422,14 +400,14 @@ class CoinsRepo { /// activated coins and retry failed activations with exponential backoff. /// /// **Retry Configuration:** - /// - Default: 500ms → 1s → 2s → 4s → 8s → 10s → 10s... (30 attempts ≈ 3 minutes) + /// - Default: 500ms → 1s → 2s → 4s → 8s → 10s → 10s... (15 attempts ≈ 105 seconds) /// - Configurable via [maxRetryAttempts], [initialRetryDelay], and [maxRetryDelay] /// /// **Parameters:** /// - [coins]: List of coins to activate /// - [notify]: Whether to broadcast state changes to listeners (default: true) /// - [addToWalletMetadata]: Whether to add assets to wallet metadata (default: true) - /// - [maxRetryAttempts]: Maximum number of retry attempts (default: 30) + /// - [maxRetryAttempts]: Maximum number of retry attempts (default: 15) /// - [initialRetryDelay]: Initial delay between retries (default: 500ms) /// - [maxRetryDelay]: Maximum delay between retries (default: 10s) /// @@ -449,7 +427,7 @@ class CoinsRepo { List coins, { bool notify = true, bool addToWalletMetadata = true, - int maxRetryAttempts = 30, + int maxRetryAttempts = 15, Duration initialRetryDelay = const Duration(milliseconds: 500), Duration maxRetryDelay = const Duration(seconds: 10), }) async { @@ -550,7 +528,23 @@ class CoinsRepo { 'select from the available options.', ) Future getFirstPubkey(String coinId) async { - final asset = _kdfSdk.assets.findAssetsByConfigId(coinId).single; + final assets = _kdfSdk.assets.findAssetsByConfigId(coinId); + if (assets.isEmpty) { + _log.warning( + 'Unable to fetch pubkey for coinId $coinId because the asset is no longer available.', + ); + return null; + } + + if (assets.length > 1) { + final assetIds = assets.map((asset) => asset.id.id).join(', '); + final message = + 'Multiple assets found for coinId $coinId while fetching pubkey: $assetIds'; + _log.shout(message); + throw StateError(message); + } + + final asset = assets.single; final pubkeys = await _kdfSdk.pubkeys.getPubkeys(asset); if (pubkeys.keys.isEmpty) { return null; @@ -583,12 +577,7 @@ class CoinsRepo { // will hit rate limits and have reduced market metrics functionality. // This will happen regardless of chunk size. The rate limits are per IP // per hour. - final coinIds = await _kdfSdk.getWalletCoinIds(); - final activatedAssets = coinIds - .map((coinId) => _kdfSdk.assets.findAssetsByConfigId(coinId)) - .where((assets) => assets.isNotEmpty) - .map((assets) => assets.single) - .toList(); + final activatedAssets = await _kdfSdk.getWalletAssets(); final Iterable targetAssets = activatedAssets.isNotEmpty ? activatedAssets : _kdfSdk.assets.available.values; diff --git a/lib/bloc/version_info/version_info_bloc.dart b/lib/bloc/version_info/version_info_bloc.dart index d8c0e94f5f..62abe28808 100644 --- a/lib/bloc/version_info/version_info_bloc.dart +++ b/lib/bloc/version_info/version_info_bloc.dart @@ -48,11 +48,11 @@ class VersionInfoBloc extends Bloc { 'Commit: $commitHash', ); - final basicInfo = VersionInfoLoaded( + var currentInfo = VersionInfoLoaded( appVersion: appVersion, commitHash: commitHash, ); - emit(basicInfo); + emit(currentInfo); try { final apiVersion = await _mm2Api.version(); @@ -63,7 +63,8 @@ class VersionInfoBloc extends Bloc { final apiCommitHash = apiVersion != null ? () => _tryParseCommitHash(apiVersion) : null; - emit(basicInfo.copyWith(apiCommitHash: apiCommitHash)); + currentInfo = currentInfo.copyWith(apiCommitHash: apiCommitHash); + emit(currentInfo); _logger.info( 'MM2 API version loaded successfully - Version: $apiVersion, ' 'Commit: ${apiCommitHash?.call()}', @@ -83,12 +84,11 @@ class VersionInfoBloc extends Bloc { ); } - emit( - basicInfo.copyWith( - currentCoinsCommit: () => _tryParseCommitHash(currentCommit ?? '-'), - latestCoinsCommit: () => _tryParseCommitHash(latestCommit ?? '-'), - ), + currentInfo = currentInfo.copyWith( + currentCoinsCommit: () => _tryParseCommitHash(currentCommit ?? '-'), + latestCoinsCommit: () => _tryParseCommitHash(latestCommit ?? '-'), ); + emit(currentInfo); _logger.info( 'SDK coins commits loaded successfully - Current: $currentCommit, ' 'Latest: $latestCommit', diff --git a/lib/model/kdf_auth_metadata_extension.dart b/lib/model/kdf_auth_metadata_extension.dart index 202cab7e84..27dfa875ad 100644 --- a/lib/model/kdf_auth_metadata_extension.dart +++ b/lib/model/kdf_auth_metadata_extension.dart @@ -1,10 +1,13 @@ import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart' show Asset; +import 'package:logging/logging.dart'; import 'package:web_dex/bloc/coins_bloc/asset_coin_extension.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/wallet.dart'; +final Logger _walletMetadataLog = Logger('KdfAuthMetadataExtension'); + extension KdfAuthMetadataExtension on KomodoDefiSdk { /// Checks if a wallet with the specified ID exists in the system. /// @@ -31,33 +34,65 @@ extension KdfAuthMetadataExtension on KomodoDefiSdk { return user?.metadata.valueOrNull>('activated_coins') ?? []; } + /// Returns the stored list of wallet assets resolved from configuration IDs. + /// + /// Missing assets (for example, delisted coins) are skipped and logged for + /// visibility. + /// + /// Throws [StateError] if multiple assets are found with the same configuration ID. + Future> getWalletAssets() async { + final coinIds = await getWalletCoinIds(); + if (coinIds.isEmpty) { + return []; + } + + final missingCoinIds = {}; + final walletAssets = []; + + for (final coinId in coinIds) { + final matchingAssets = assets.findAssetsByConfigId(coinId); + if (matchingAssets.isEmpty) { + missingCoinIds.add(coinId); + continue; + } + + if (matchingAssets.length > 1) { + final assetIds = matchingAssets.map((asset) => asset.id.id).join(', '); + final message = + 'Multiple assets found for activated coin ID "$coinId": $assetIds'; + _walletMetadataLog.shout(message); + throw StateError(message); + } + + walletAssets.add(matchingAssets.single); + } + + if (missingCoinIds.isNotEmpty) { + _walletMetadataLog.warning( + 'Skipping ${missingCoinIds.length} activated coin(s) that are no longer ' + 'available in the SDK (likely delisted): ' + '${missingCoinIds.join(', ')}', + ); + } + + return walletAssets; + } + /// Returns the stored list of wallet coins converted from asset configuration IDs. /// /// This method retrieves the coin IDs from user metadata and converts them /// to [Coin] objects. Uses `single` to maintain existing behavior which will /// throw an exception if multiple assets share the same ticker. /// + /// Missing assets (for example, delisted coins) are skipped and logged for + /// visibility. + /// /// If no user is signed in, returns an empty list. /// /// Throws [StateError] if multiple assets are found with the same configuration ID. Future> getWalletCoins() async { - final assets = await getWalletAssets(); - return assets - // use single to stick to the existing behaviour around assetByTicker - // which will cause the application to crash if there are - // multiple assets with the same ticker - .map((asset) => asset.toCoin()) - .toList(); - } - - Future> getWalletAssets() async { - final coinIds = await getWalletCoinIds(); - return coinIds - // use single to stick to the existing behaviour around assetByTicker - // which will cause the application to crash if there are - // multiple assets with the same ticker - .map((coinId) => assets.findAssetsByConfigId(coinId).single) - .toList(); + final walletAssets = await getWalletAssets(); + return walletAssets.map((asset) => asset.toCoin()).toList(); } /// Adds new coin/asset IDs to the current user's activated coins list. diff --git a/test_integration/bloc/cex_market_data/common/update_frequency_backoff_strategy_integration_test.dart b/test_integration/bloc/cex_market_data/common/update_frequency_backoff_strategy_integration_test.dart new file mode 100644 index 0000000000..aa4ffd11f9 --- /dev/null +++ b/test_integration/bloc/cex_market_data/common/update_frequency_backoff_strategy_integration_test.dart @@ -0,0 +1,136 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:web_dex/bloc/cex_market_data/common/update_frequency_backoff_strategy.dart'; + +void main() { + group('UpdateFrequencyBackoffStrategy Integration Tests', () { + test('should demonstrate realistic backoff progression over time', () { + final strategy = UpdateFrequencyBackoffStrategy(); + final List actualIntervals = []; + + // Simulate 20 update attempts + for (int i = 0; i < 20; i++) { + actualIntervals.add(strategy.getNextInterval()); + } + + // Verify the pattern: 2min pairs, then 4min pairs, then 8min pairs, etc. + expect(actualIntervals[0], const Duration(minutes: 2)); // Attempt 0 + expect(actualIntervals[1], const Duration(minutes: 2)); // Attempt 1 + expect(actualIntervals[2], const Duration(minutes: 4)); // Attempt 2 + expect(actualIntervals[3], const Duration(minutes: 4)); // Attempt 3 + expect(actualIntervals[4], const Duration(minutes: 8)); // Attempt 4 + expect(actualIntervals[5], const Duration(minutes: 8)); // Attempt 5 + expect(actualIntervals[6], const Duration(minutes: 16)); // Attempt 6 + expect(actualIntervals[7], const Duration(minutes: 16)); // Attempt 7 + expect(actualIntervals[8], const Duration(minutes: 32)); // Attempt 8 + expect(actualIntervals[9], const Duration(minutes: 32)); // Attempt 9 + expect(actualIntervals[10], const Duration(minutes: 60)); // Capped at 1 hour + expect(actualIntervals[11], const Duration(minutes: 60)); // Capped at 1 hour + + // Verify that all subsequent intervals are capped at max + for (int i = 12; i < actualIntervals.length; i++) { + expect(actualIntervals[i], const Duration(minutes: 60)); + } + }); + + test('should reduce API calls over time compared to fixed interval', () { + final strategy = UpdateFrequencyBackoffStrategy(); + + // Calculate total time and API calls over 24 hours with backoff strategy + const simulationDuration = Duration(hours: 24); + int backoffApiCalls = 0; + Duration totalBackoffTime = Duration.zero; + + while (totalBackoffTime < simulationDuration) { + final interval = strategy.getNextInterval(); + totalBackoffTime += interval; + backoffApiCalls++; + } + + // Calculate API calls with fixed 2-minute interval + const fixedInterval = Duration(minutes: 2); + final fixedApiCalls = simulationDuration.inMinutes ~/ fixedInterval.inMinutes; + + // Backoff strategy should make significantly fewer API calls + expect(backoffApiCalls, lessThan(fixedApiCalls)); + expect(backoffApiCalls, lessThan(fixedApiCalls * 0.5)); // Less than 50% of fixed calls + + print('Fixed interval (2min): $fixedApiCalls API calls in 24h'); + print('Backoff strategy: $backoffApiCalls API calls in 24h'); + print('Reduction: ${((fixedApiCalls - backoffApiCalls) / fixedApiCalls * 100).toStringAsFixed(1)}%'); + }); + + test('should recover quickly after reset', () { + final strategy = UpdateFrequencyBackoffStrategy(); + + // Advance to high attempt count + for (int i = 0; i < 10; i++) { + strategy.getNextInterval(); + } + + // Should be at a high interval + expect(strategy.getCurrentInterval(), greaterThan(const Duration(minutes: 10))); + + // Reset and verify quick recovery + strategy.reset(); + expect(strategy.getNextInterval(), const Duration(minutes: 2)); + expect(strategy.getNextInterval(), const Duration(minutes: 2)); + expect(strategy.getNextInterval(), const Duration(minutes: 4)); + }); + + test('should handle custom intervals for different use cases', () { + // Test for a more aggressive backoff (shorter max interval) + final aggressiveStrategy = UpdateFrequencyBackoffStrategy( + baseInterval: const Duration(minutes: 1), + maxInterval: const Duration(minutes: 10), + ); + + // Test for a more conservative backoff (longer base interval) + final conservativeStrategy = UpdateFrequencyBackoffStrategy( + baseInterval: const Duration(minutes: 5), + maxInterval: const Duration(hours: 2), + ); + + // Aggressive should reach max quickly (after 6 attempts: 1,1,2,2,4,4,8...) + for (int i = 0; i < 6; i++) { + aggressiveStrategy.getNextInterval(); + } + expect(aggressiveStrategy.getCurrentInterval(), const Duration(minutes: 8)); + + // Conservative should start and progress more slowly + expect(conservativeStrategy.getNextInterval(), const Duration(minutes: 5)); + expect(conservativeStrategy.getNextInterval(), const Duration(minutes: 5)); + expect(conservativeStrategy.getNextInterval(), const Duration(minutes: 10)); + expect(conservativeStrategy.getNextInterval(), const Duration(minutes: 10)); + }); + + test('should be suitable for portfolio update scenarios', () { + final strategy = UpdateFrequencyBackoffStrategy(); + + // First hour of updates (user just logged in) + final firstHourIntervals = []; + Duration elapsed = Duration.zero; + const oneHour = Duration(hours: 1); + + while (elapsed < oneHour) { + final interval = strategy.getNextInterval(); + firstHourIntervals.add(interval); + elapsed += interval; + } + + // Should have frequent updates in the first hour + expect(firstHourIntervals.length, greaterThan(5)); + expect(firstHourIntervals.length, lessThan(30)); // But not too frequent + + // First few updates should be relatively quick + expect(firstHourIntervals[0], const Duration(minutes: 2)); + expect(firstHourIntervals[1], const Duration(minutes: 2)); + + // Later updates should be less frequent + final lastInterval = firstHourIntervals.last; + expect(lastInterval, greaterThan(const Duration(minutes: 2))); + + print('Updates in first hour: ${firstHourIntervals.length}'); + print('Intervals: ${firstHourIntervals.map((d) => '${d.inMinutes}min').join(', ')}'); + }); + }); +} \ No newline at end of file diff --git a/test_units/bloc/cex_market_data/common/update_frequency_backoff_strategy_test.dart b/test_units/bloc/cex_market_data/common/update_frequency_backoff_strategy_test.dart new file mode 100644 index 0000000000..6dbb7c5fbc --- /dev/null +++ b/test_units/bloc/cex_market_data/common/update_frequency_backoff_strategy_test.dart @@ -0,0 +1,164 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:web_dex/bloc/cex_market_data/common/update_frequency_backoff_strategy.dart'; + +void main() { + group('UpdateFrequencyBackoffStrategy', () { + late UpdateFrequencyBackoffStrategy strategy; + + setUp(() { + strategy = UpdateFrequencyBackoffStrategy(); + }); + + test('should start with attempt count 0', () { + expect(strategy.attemptCount, 0); + }); + + test('should return base interval for first two attempts', () { + expect(strategy.getCurrentInterval(), const Duration(minutes: 2)); + expect(strategy.getNextInterval(), const Duration(minutes: 2)); + expect(strategy.attemptCount, 1); + + expect(strategy.getCurrentInterval(), const Duration(minutes: 2)); + expect(strategy.getNextInterval(), const Duration(minutes: 2)); + expect(strategy.attemptCount, 2); + }); + + test('should double interval for next pair of attempts', () { + // Skip first two attempts + strategy.getNextInterval(); // 2 min + strategy.getNextInterval(); // 2 min + + expect(strategy.getCurrentInterval(), const Duration(minutes: 4)); + expect(strategy.getNextInterval(), const Duration(minutes: 4)); + expect(strategy.attemptCount, 3); + + expect(strategy.getCurrentInterval(), const Duration(minutes: 4)); + expect(strategy.getNextInterval(), const Duration(minutes: 4)); + expect(strategy.attemptCount, 4); + }); + + test('should follow exponential backoff pattern: 2,2,4,4,8,8,16,16', () { + final expectedIntervals = [ + const Duration(minutes: 2), // attempt 0 + const Duration(minutes: 2), // attempt 1 + const Duration(minutes: 4), // attempt 2 + const Duration(minutes: 4), // attempt 3 + const Duration(minutes: 8), // attempt 4 + const Duration(minutes: 8), // attempt 5 + const Duration(minutes: 16), // attempt 6 + const Duration(minutes: 16), // attempt 7 + ]; + + for (int i = 0; i < expectedIntervals.length; i++) { + expect( + strategy.getNextInterval(), + expectedIntervals[i], + reason: 'Attempt $i should return ${expectedIntervals[i]}', + ); + } + }); + + test('should cap at maximum interval', () { + strategy = UpdateFrequencyBackoffStrategy( + baseInterval: const Duration(minutes: 1), + maxInterval: const Duration(minutes: 5), + ); + + // Skip to high attempt count to reach max + for (int i = 0; i < 10; i++) { + strategy.getNextInterval(); + } + + // Should be capped at 5 minutes + expect(strategy.getCurrentInterval(), const Duration(minutes: 5)); + }); + + test('should reset to initial state', () { + // Make some attempts + strategy.getNextInterval(); + strategy.getNextInterval(); + strategy.getNextInterval(); + + expect(strategy.attemptCount, 3); + expect(strategy.getCurrentInterval(), const Duration(minutes: 4)); + + // Reset + strategy.reset(); + + expect(strategy.attemptCount, 0); + expect(strategy.getCurrentInterval(), const Duration(minutes: 2)); + }); + + test('should always return true for shouldUpdateCache', () { + // Test for various attempt counts + for (int i = 0; i < 10; i++) { + expect(strategy.shouldUpdateCache(), true); + strategy.getNextInterval(); + } + }); + + test('should preview next intervals without changing state', () { + // Start at attempt count 0 + expect(strategy.attemptCount, 0); + + final preview = strategy.previewNextIntervals(6); + + // State should be unchanged + expect(strategy.attemptCount, 0); + + // Preview should show correct intervals + expect(preview, [ + const Duration(minutes: 2), // attempt 0 + const Duration(minutes: 2), // attempt 1 + const Duration(minutes: 4), // attempt 2 + const Duration(minutes: 4), // attempt 3 + const Duration(minutes: 8), // attempt 4 + const Duration(minutes: 8), // attempt 5 + ]); + }); + + test('should preview intervals from current position', () { + // Advance to attempt 2 + strategy.getNextInterval(); // 2 min + strategy.getNextInterval(); // 2 min + + expect(strategy.attemptCount, 2); + + final preview = strategy.previewNextIntervals(4); + + // Should show intervals starting from attempt 2 + expect(preview, [ + const Duration(minutes: 4), // attempt 2 + const Duration(minutes: 4), // attempt 3 + const Duration(minutes: 8), // attempt 4 + const Duration(minutes: 8), // attempt 5 + ]); + + // State should be unchanged + expect(strategy.attemptCount, 2); + }); + + test('should handle custom base and max intervals', () { + strategy = UpdateFrequencyBackoffStrategy( + baseInterval: const Duration(minutes: 1), + maxInterval: const Duration(minutes: 3), + ); + + final intervals = [ + strategy.getNextInterval(), // 1min + strategy.getNextInterval(), // 1min + strategy.getNextInterval(), // 2min + strategy.getNextInterval(), // 2min + strategy.getNextInterval(), // 3min (capped at max) + ]; + + expect(intervals, [ + const Duration(minutes: 1), + const Duration(minutes: 1), + const Duration(minutes: 2), + const Duration(minutes: 2), + const Duration(minutes: 3), // Capped + ]); + }); + }); +} \ No newline at end of file