Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 86 additions & 28 deletions lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@ part 'portfolio_growth_state.dart';
class PortfolioGrowthBloc
extends Bloc<PortfolioGrowthEvent, PortfolioGrowthState> {
PortfolioGrowthBloc({
required this.portfolioGrowthRepository,
required this.sdk,
}) : super(const PortfolioGrowthInitial()) {
required PortfolioGrowthRepository portfolioGrowthRepository,
required KomodoDefiSdk sdk,
}) : _sdk = sdk,
_portfolioGrowthRepository = portfolioGrowthRepository,
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
// than the previous event can complete).
Expand All @@ -38,8 +40,8 @@ class PortfolioGrowthBloc
on<PortfolioGrowthClearRequested>(_onClearPortfolioGrowth);
}

final PortfolioGrowthRepository portfolioGrowthRepository;
final KomodoDefiSdk sdk;
final PortfolioGrowthRepository _portfolioGrowthRepository;
final KomodoDefiSdk _sdk;
final _log = Logger('PortfolioGrowthBloc');

void _onClearPortfolioGrowth(
Expand All @@ -53,6 +55,8 @@ class PortfolioGrowthBloc
PortfolioGrowthPeriodChanged event,
Emitter<PortfolioGrowthState> emit,
) {
final (int totalCoins, int coinsWithKnownBalance, int coinsWithKnownBalanceAndFiat) =
_calculateCoinProgressCounters(event.coins);
final currentState = state;
if (currentState is PortfolioGrowthChartLoadSuccess) {
emit(
Expand All @@ -63,6 +67,9 @@ class PortfolioGrowthBloc
totalBalance: currentState.totalBalance,
totalChange24h: currentState.totalChange24h,
percentageChange24h: currentState.percentageChange24h,
totalCoins: totalCoins,
coinsWithKnownBalance: coinsWithKnownBalance,
coinsWithKnownBalanceAndFiat: coinsWithKnownBalanceAndFiat,
isUpdating: true,
),
);
Expand All @@ -71,11 +78,19 @@ class PortfolioGrowthBloc
GrowthChartLoadFailure(
error: currentState.error,
selectedPeriod: event.selectedPeriod,
totalCoins: totalCoins,
coinsWithKnownBalance: coinsWithKnownBalance,
coinsWithKnownBalanceAndFiat: coinsWithKnownBalanceAndFiat,
),
);
} else if (currentState is PortfolioGrowthChartUnsupported) {
emit(
PortfolioGrowthChartUnsupported(selectedPeriod: event.selectedPeriod),
PortfolioGrowthChartUnsupported(
selectedPeriod: event.selectedPeriod,
totalCoins: totalCoins,
coinsWithKnownBalance: coinsWithKnownBalance,
coinsWithKnownBalanceAndFiat: coinsWithKnownBalanceAndFiat,
),
);
} else {
emit(const PortfolioGrowthInitial());
Expand All @@ -101,8 +116,18 @@ class PortfolioGrowthBloc
// 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 (
int totalCoins,
int coinsWithKnownBalance,
int coinsWithKnownBalanceAndFiat,
) = _calculateCoinProgressCounters(event.coins);
return emit(
PortfolioGrowthChartUnsupported(selectedPeriod: event.selectedPeriod),
PortfolioGrowthChartUnsupported(
selectedPeriod: event.selectedPeriod,
totalCoins: totalCoins,
coinsWithKnownBalance: coinsWithKnownBalance,
coinsWithKnownBalanceAndFiat: coinsWithKnownBalanceAndFiat,
),
);
}

Expand All @@ -120,7 +145,7 @@ 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(event.coins);

// Only remove inactivate/activating coins after an attempt to load the
// cached chart, as the cached chart may contain inactive coins.
Expand All @@ -143,12 +168,9 @@ class PortfolioGrowthBloc
// recover at the cost of a longer first loading time.
}

final periodicUpdate = Stream<Object?>.periodic(event.updateFrequency)
.asyncMap((_) async {
// Update prices before fetching chart data
await portfolioGrowthRepository.updatePrices();
return _fetchPortfolioGrowthChart(event);
});
final periodicUpdate = Stream<Object?>.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
Expand All @@ -164,10 +186,18 @@ class PortfolioGrowthBloc
);
} 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,
),
);
}
Expand All @@ -179,7 +209,7 @@ class PortfolioGrowthBloc
) async {
final List<Coin> coins = List.from(event.coins);
for (final coin in event.coins) {
final isCoinSupported = await portfolioGrowthRepository
final isCoinSupported = await _portfolioGrowthRepository
.isCoinChartSupported(coin.id, event.fiatCoinId);
if (!isCoinSupported) {
coins.remove(coin);
Expand All @@ -193,7 +223,7 @@ class PortfolioGrowthBloc
PortfolioGrowthLoadRequested event, {
required bool useCache,
}) async {
final chart = await portfolioGrowthRepository.getPortfolioGrowthChart(
final chart = await _portfolioGrowthRepository.getPortfolioGrowthChart(
coins,
fiatCoinId: event.fiatCoinId,
walletId: event.walletId,
Expand All @@ -204,21 +234,23 @@ class PortfolioGrowthBloc
return state;
}

// Fetch prices before calculating total change
// This ensures we have the latest prices in the cache
await portfolioGrowthRepository.updatePrices();

final totalBalance = _calculateTotalBalance(coins);
final totalChange24h = await _calculateTotalChange24h(coins);
final percentageChange24h = await _calculatePercentageChange24h(coins);

final (int totalCoins, int coinsWithKnownBalance, int coinsWithKnownBalanceAndFiat) =
_calculateCoinProgressCounters(event.coins);

return PortfolioGrowthChartLoadSuccess(
portfolioGrowth: chart,
percentageIncrease: chart.percentageIncrease,
selectedPeriod: event.selectedPeriod,
totalBalance: totalBalance,
totalChange24h: totalChange24h.toDouble(),
percentageChange24h: percentageChange24h.toDouble(),
totalCoins: totalCoins,
coinsWithKnownBalance: coinsWithKnownBalance,
coinsWithKnownBalanceAndFiat: coinsWithKnownBalanceAndFiat,
isUpdating: false,
);
}
Expand All @@ -230,7 +262,7 @@ class PortfolioGrowthBloc
try {
final supportedCoins = await _removeUnsupportedCoins(event);
final coins = await _removeInactiveCoins(supportedCoins);
return await portfolioGrowthRepository.getPortfolioGrowthChart(
return await _portfolioGrowthRepository.getPortfolioGrowthChart(
coins,
fiatCoinId: event.fiatCoinId,
walletId: event.walletId,
Expand All @@ -244,7 +276,7 @@ class PortfolioGrowthBloc

Future<List<Coin>> _removeInactiveCoins(List<Coin> coins) async {
final coinsCopy = List<Coin>.of(coins);
final activeCoins = await sdk.assets.getActivatedAssets();
final activeCoins = await _sdk.assets.getActivatedAssets();
final activeCoinsMap = activeCoins.map((e) => e.id).toSet();
for (final coin in coins) {
if (!activeCoinsMap.contains(coin.id)) {
Expand All @@ -268,13 +300,19 @@ class PortfolioGrowthBloc
final totalChange24h = await _calculateTotalChange24h(coins);
final percentageChange24h = await _calculatePercentageChange24h(coins);

final (int totalCoins, int coinsWithKnownBalance, int coinsWithKnownBalanceAndFiat) =
_calculateCoinProgressCounters(coins);

return PortfolioGrowthChartLoadSuccess(
portfolioGrowth: growthChart,
percentageIncrease: percentageIncrease,
selectedPeriod: selectedPeriod,
totalBalance: totalBalance,
totalChange24h: totalChange24h.toDouble(),
percentageChange24h: percentageChange24h.toDouble(),
totalCoins: totalCoins,
coinsWithKnownBalance: coinsWithKnownBalance,
coinsWithKnownBalanceAndFiat: coinsWithKnownBalanceAndFiat,
isUpdating: false,
);
}
Expand All @@ -283,7 +321,7 @@ class PortfolioGrowthBloc
double _calculateTotalBalance(List<Coin> coins) {
double total = coins.fold(
0,
(prev, coin) => prev + (coin.lastKnownUsdBalance(sdk) ?? 0),
(prev, coin) => prev + (coin.lastKnownUsdBalance(_sdk) ?? 0),
);

// Return at least 0.01 if total is positive but very small
Expand All @@ -295,15 +333,14 @@ class PortfolioGrowthBloc
}

/// Calculate the total 24h change in USD value
/// TODO: look into avoiding zero default values here if no data is available
Future<Rational> _calculateTotalChange24h(List<Coin> coins) async {
Rational totalChange = Rational.zero;
for (final coin in coins) {
final double usdBalance = coin.lastKnownUsdBalance(sdk) ?? 0.0;
final double usdBalance = coin.lastKnownUsdBalance(_sdk) ?? 0.0;
final usdBalanceDecimal = Decimal.parse(usdBalance.toString());
final price = portfolioGrowthRepository.getCachedPrice(
coin.id.symbol.configSymbol.toUpperCase(),
);
final change24h = price?.change24h ?? Decimal.zero;
final change24h =
await _sdk.marketData.priceChange24h(coin.id) ?? Decimal.zero;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Direct SDK Call Without Error Handling

The _calculateTotalChange24h method now calls _sdk.marketData.priceChange24h directly within a loop. This call lacks exception handling, meaning if the SDK method throws an exception for any coin, the entire portfolio change calculation will fail. This is less resilient than the previous cached approach and inconsistent with error handling for the same SDK call elsewhere in the codebase.

Fix in Cursor Fix in Web

totalChange += change24h * usdBalanceDecimal / Decimal.fromInt(100);
}
return totalChange;
Expand All @@ -325,4 +362,25 @@ class PortfolioGrowthBloc
// 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
/// - coinsWithKnownBalanceAndFiat: number of coins with a known last balance and known fiat price
(int, int, int) _calculateCoinProgressCounters(List<Coin> coins) {
int totalCoins = coins.length;
int withBalance = 0;
int withBalanceAndFiat = 0;
for (final coin in coins) {
final balanceKnown = _sdk.balances.lastKnown(coin.id) != null;
if (balanceKnown) {
withBalance++;
final priceKnown = _sdk.marketData.priceIfKnown(coin.id) != null;
if (priceKnown) {
withBalanceAndFiat++;
}
}
}
return (totalCoins, withBalance, withBalanceAndFiat);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import 'package:komodo_defi_sdk/komodo_defi_sdk.dart';
import 'package:komodo_defi_types/komodo_defi_types.dart';
import 'package:komodo_persistence_layer/komodo_persistence_layer.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/mockup/mock_portfolio_growth_repository.dart';
import 'package:web_dex/bloc/cex_market_data/mockup/performance_mode.dart';
Expand All @@ -22,7 +21,6 @@ import 'package:web_dex/bloc/cex_market_data/models/models.dart';
import 'package:web_dex/bloc/cex_market_data/portfolio_growth/cache_miss_exception.dart';
import 'package:web_dex/bloc/coins_bloc/coins_repo.dart';
import 'package:web_dex/bloc/transaction_history/transaction_history_repo.dart';
import 'package:web_dex/model/cex_price.dart' show CexPrice;
import 'package:web_dex/model/coin.dart';
import 'package:web_dex/shared/utils/extensions/legacy_coin_migration_extensions.dart';

Expand Down Expand Up @@ -555,39 +553,4 @@ class PortfolioGrowthRepository {
}

Future<void> clearCache() => _graphCache.deleteAll();

/// Calculate the total 24h change in USD value for a list of coins
///
/// This method fetches the current prices for all coins and calculates
/// the 24h change by multiplying each coin's percentage change by its USD balance
Future<Rational> calculateTotalChange24h(List<Coin> coins) async {
// Fetch current prices including 24h change data
final prices = await _coinsRepository.fetchCurrentPrices() ?? {};

// Calculate the 24h change by summing the change percentage of each coin
// multiplied by its USD balance and divided by 100 (to convert percentage to decimal)
Rational totalChange = Rational.zero;
for (final coin in coins) {
final price = prices[coin.id.symbol.configSymbol.toUpperCase()];
final change24h = price?.change24h ?? Decimal.zero;
final usdBalance = coin.lastKnownUsdBalance(_sdk) ?? 0.0;
final usdBalanceDecimal = Decimal.parse(usdBalance.toString());
totalChange += (change24h * usdBalanceDecimal / Decimal.fromInt(100));
}
return totalChange;
}

/// Get the cached price for a given coin symbol
///
/// This is used to avoid fetching prices for every calculation
CexPrice? getCachedPrice(String symbol) {
return _coinsRepository.getCachedPrice(symbol);
}

/// Update prices for all coins by fetching from market data
///
/// This method ensures we have up-to-date price data before calculations
Future<void> updatePrices() async {
await _coinsRepository.fetchCurrentPrices();
}
}
Loading
Loading