diff --git a/lib/bloc/app_bloc_root.dart b/lib/bloc/app_bloc_root.dart index be87f65c89..2dd6bec3d9 100644 --- a/lib/bloc/app_bloc_root.dart +++ b/lib/bloc/app_bloc_root.dart @@ -19,6 +19,8 @@ import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; import 'package:web_dex/bloc/bitrefill/bloc/bitrefill_bloc.dart'; import 'package:web_dex/bloc/bridge_form/bridge_bloc.dart'; import 'package:web_dex/bloc/bridge_form/bridge_repository.dart'; +import 'package:web_dex/bloc/cex_market_data/mockup/generator.dart'; +import 'package:web_dex/bloc/cex_market_data/mockup/mock_transaction_history_repository.dart'; import 'package:web_dex/bloc/cex_market_data/mockup/performance_mode.dart'; import 'package:web_dex/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart'; import 'package:web_dex/bloc/cex_market_data/portfolio_growth/portfolio_growth_repository.dart'; @@ -76,7 +78,7 @@ class AppBlocRoot extends StatelessWidget { final KomodoDefiSdk komodoDefiSdk; // TODO: Refactor to clean up the bloat in this main file - void _clearCachesIfPerformanceModeChanged( + Future _clearCachesIfPerformanceModeChanged( PerformanceMode? performanceMode, ProfitLossRepository profitLossRepo, PortfolioGrowthRepository portfolioGrowthRepo, @@ -115,16 +117,12 @@ class AppBlocRoot extends StatelessWidget { final trezorRepo = RepositoryProvider.of(context); final trezorBloc = RepositoryProvider.of(context); - // TODO: SDK Port needed, not sure about this part - final transactionsRepo = /*performanceMode != null + final transactionsRepo = performanceMode != null ? MockTransactionHistoryRepo( - api: mm2Api, - client: Client(), performanceMode: performanceMode, demoDataGenerator: DemoDataCache.withDefaults(), ) - : */ - SdkTransactionHistoryRepository(sdk: komodoDefiSdk); + : SdkTransactionHistoryRepository(sdk: komodoDefiSdk); final profitLossRepo = ProfitLossRepository.withDefaults( transactionHistoryRepo: transactionsRepo, @@ -132,8 +130,7 @@ class AppBlocRoot extends StatelessWidget { // Returns real data if performanceMode is null. Consider changing the // other repositories to use this pattern. demoMode: performanceMode, - coinsRepository: coinsRepository, - mm2Api: mm2Api, + sdk: komodoDefiSdk, ); final portfolioGrowthRepo = PortfolioGrowthRepository.withDefaults( @@ -141,7 +138,7 @@ class AppBlocRoot extends StatelessWidget { cexRepository: binanceRepository, demoMode: performanceMode, coinsRepository: coinsRepository, - mm2Api: mm2Api, + sdk: komodoDefiSdk, ); _clearCachesIfPerformanceModeChanged( @@ -201,21 +198,21 @@ class AppBlocRoot extends StatelessWidget { ), BlocProvider( create: (context) => AssetOverviewBloc( - investmentRepository: InvestmentRepository( - profitLossRepository: profitLossRepo, - ), - profitLossRepository: profitLossRepo, + profitLossRepo, + InvestmentRepository(profitLossRepository: profitLossRepo), + komodoDefiSdk, ), ), BlocProvider( create: (context) => ProfitLossBloc( - profitLossRepository: profitLossRepo, + profitLossRepo, + komodoDefiSdk, ), ), BlocProvider( create: (BuildContext ctx) => PortfolioGrowthBloc( portfolioGrowthRepository: portfolioGrowthRepo, - coinsRepository: coinsRepository, + sdk: komodoDefiSdk, ), ), BlocProvider( diff --git a/lib/bloc/assets_overview/bloc/asset_overview_bloc.dart b/lib/bloc/assets_overview/bloc/asset_overview_bloc.dart index b47cc1c1e3..231e16316e 100644 --- a/lib/bloc/assets_overview/bloc/asset_overview_bloc.dart +++ b/lib/bloc/assets_overview/bloc/asset_overview_bloc.dart @@ -2,21 +2,24 @@ import 'dart:async'; import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; +import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; +import 'package:logging/logging.dart'; import 'package:web_dex/bloc/assets_overview/investment_repository.dart'; import 'package:web_dex/bloc/cex_market_data/profit_loss/models/fiat_value.dart'; import 'package:web_dex/bloc/cex_market_data/profit_loss/models/profit_loss.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/model/coin.dart'; -import 'package:web_dex/shared/utils/utils.dart' as logger; part 'asset_overview_event.dart'; part 'asset_overview_state.dart'; class AssetOverviewBloc extends Bloc { - AssetOverviewBloc({ - required this.profitLossRepository, - required this.investmentRepository, - }) : super(const AssetOverviewInitial()) { + AssetOverviewBloc( + this._profitLossRepository, + this._investmentRepository, + this._sdk, + ) : super(const AssetOverviewInitial()) { on(_onLoad); on(_onClear); on(_onLoadPortfolio); @@ -26,9 +29,10 @@ class AssetOverviewBloc extends Bloc { on(_onUnsubscribePortfolio); } - final ProfitLossRepository profitLossRepository; - final InvestmentRepository investmentRepository; - + final ProfitLossRepository _profitLossRepository; + final InvestmentRepository _investmentRepository; + final KomodoDefiSdk _sdk; + final _log = Logger('AssetOverviewBloc'); Timer? _updateTimer; Future _onLoad( @@ -38,14 +42,14 @@ class AssetOverviewBloc extends Bloc { emit(const AssetOverviewLoadInProgress()); try { - final profitLosses = await profitLossRepository.getProfitLoss( + final profitLosses = await _profitLossRepository.getProfitLoss( event.coin.id, 'USDT', event.walletId, ); final totalInvestment = - await investmentRepository.calculateTotalInvestment( + await _investmentRepository.calculateTotalInvestment( event.walletId, [event.coin], ); @@ -65,8 +69,8 @@ class AssetOverviewBloc extends Bloc { investmentReturnPercentage: investmentReturnPercentage, ), ); - } catch (e) { - logger.log('Failed to load asset overview: $e', isError: true); + } catch (e, s) { + _log.shout('Failed to load asset overview', e, s); if (state is! AssetOverviewLoadSuccess) { emit(AssetOverviewLoadFailure(error: e.toString())); } @@ -86,16 +90,20 @@ class AssetOverviewBloc extends Bloc { PortfolioAssetsOverviewLoadRequested event, Emitter emit, ) async { - // nothing listens to this. The UI just resets to default values, i.e. 0 - // emit(const AssetOverviewLoadInProgress()); - try { + if (event.coins.isEmpty) { + _log.warning('No coins to load portfolio overview for'); + return; + } + + await _sdk.waitForEnabledCoinsToPassThreshold(event.coins); + final profitLossesFutures = event.coins.map((coin) async { // Catch errors that occur for single coins and exclude them from the // total so that transaction fetching errors for a single coin do not // affect the total investment calculation. try { - return await profitLossRepository.getProfitLoss( + return await _profitLossRepository.getProfitLoss( coin.id, 'USDT', event.walletId, @@ -108,7 +116,7 @@ class AssetOverviewBloc extends Bloc { final profitLosses = await Future.wait(profitLossesFutures); final totalInvestment = - await investmentRepository.calculateTotalInvestment( + await _investmentRepository.calculateTotalInvestment( event.walletId, event.coins, ); @@ -125,7 +133,7 @@ class AssetOverviewBloc extends Bloc { emit( PortfolioAssetsOverviewLoadSuccess( - selectedAssetIds: event.coins.map((coin) => coin.abbr).toList(), + selectedAssetIds: event.coins.map((coin) => coin.id.id).toList(), assetPortionPercentages: assetPortionPercentages, totalInvestment: totalInvestment, totalValue: FiatValue.usd(profitAmount), @@ -133,8 +141,8 @@ class AssetOverviewBloc extends Bloc { profitIncreasePercentage: portfolioInvestmentReturnPercentage, ), ); - } catch (e) { - logger.log('Failed to load portfolio assets overview: $e', isError: true); + } catch (e, s) { + _log.shout('Failed to load portfolio assets overview', e, s); if (state is! PortfolioAssetsOverviewLoadSuccess) { emit(AssetOverviewLoadFailure(error: e.toString())); } diff --git a/lib/bloc/auth_bloc/auth_bloc.dart b/lib/bloc/auth_bloc/auth_bloc.dart index 95f6ccbab8..6998adf3cd 100644 --- a/lib/bloc/auth_bloc/auth_bloc.dart +++ b/lib/bloc/auth_bloc/auth_bloc.dart @@ -4,11 +4,12 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; 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/blocs/wallets_repository.dart'; import 'package:web_dex/model/authorize_mode.dart'; import 'package:web_dex/model/kdf_auth_metadata_extension.dart'; import 'package:web_dex/model/wallet.dart'; -import 'package:web_dex/shared/utils/utils.dart'; part 'auth_bloc_event.dart'; part 'auth_bloc_state.dart'; @@ -33,6 +34,7 @@ class AuthBloc extends Bloc { final KomodoDefiSdk _kdfSdk; final WalletsRepository _walletsRepository; StreamSubscription? _authChangesSubscription; + final _log = Logger('AuthBloc'); @override Future close() async { @@ -44,7 +46,7 @@ class AuthBloc extends Bloc { AuthSignOutRequested event, Emitter emit, ) async { - log('Logging out from a wallet', path: 'auth_bloc => _logOut').ignore(); + _log.info('Logging out from a wallet'); emit(AuthBlocState.loading()); await _kdfSdk.auth.signOut(); await _authChangesSubscription?.cancel(); @@ -66,7 +68,7 @@ class AuthBloc extends Bloc { ); } - log('login from a wallet', path: 'auth_bloc => _reLogin').ignore(); + _log.info('login from a wallet'); emit(AuthBlocState.loading()); await _kdfSdk.auth.signIn( walletName: event.wallet.name, @@ -82,13 +84,12 @@ class AuthBloc extends Bloc { return emit(AuthBlocState.error('Failed to login')); } - log('logged in from a wallet', path: 'auth_bloc => _reLogin').ignore(); + _log.info('logged in from a wallet'); emit(AuthBlocState.loggedIn(currentUser)); _listenToAuthStateChanges(); } catch (e, s) { final error = 'Failed to login wallet ${event.wallet.name}'; - log(error, isError: true, trace: s, path: 'auth_bloc -> onLogin') - .ignore(); + _log.shout(error, e, s); emit(AuthBlocState.error(error)); await _authChangesSubscription?.cancel(); } @@ -119,7 +120,7 @@ class AuthBloc extends Bloc { return; } - log('register from a wallet', path: 'auth_bloc => _register').ignore(); + _log.info('register from a wallet'); await _kdfSdk.auth.register( password: event.password, walletName: event.wallet.name, @@ -130,13 +131,11 @@ class AuthBloc extends Bloc { ), ); - if (!await _kdfSdk.auth.isSignedIn()) { - throw Exception('Registration failed: user is not signed in'); - } - - log('registered from a wallet', path: 'auth_bloc => _register').ignore(); + _log.info('registered from a wallet'); await _kdfSdk.setWalletType(event.wallet.config.type); await _kdfSdk.confirmSeedBackup(hasBackup: false); + await _kdfSdk.addActivatedCoins(enabledByDefaultCoins); + final currentUser = await _kdfSdk.auth.currentUser; if (currentUser == null) { throw Exception('Registration failed: user is not signed in'); @@ -145,8 +144,7 @@ class AuthBloc extends Bloc { _listenToAuthStateChanges(); } catch (e, s) { final error = 'Failed to register wallet ${event.wallet.name}'; - log(error, isError: true, trace: s, path: 'auth_bloc -> onRegister') - .ignore(); + _log.shout(error, e, s); emit(AuthBlocState.error(error)); await _authChangesSubscription?.cancel(); } @@ -162,7 +160,7 @@ class AuthBloc extends Bloc { return; } - log('restore from a wallet', path: 'auth_bloc => _restore').ignore(); + _log.info('restore from a wallet'); await _kdfSdk.auth.register( password: event.password, walletName: event.wallet.name, @@ -174,13 +172,11 @@ class AuthBloc extends Bloc { ), ); - if (!await _kdfSdk.auth.isSignedIn()) { - throw Exception('Registration failed: user is not signed in'); - } - - log('restored from a wallet', path: 'auth_bloc => _restore').ignore(); + _log.info('restored from a wallet'); await _kdfSdk.setWalletType(event.wallet.config.type); await _kdfSdk.confirmSeedBackup(hasBackup: event.wallet.config.hasBackup); + await _kdfSdk.addActivatedCoins(enabledByDefaultCoins); + final currentUser = await _kdfSdk.auth.currentUser; if (currentUser == null) { throw Exception('Registration failed: user is not signed in'); @@ -197,8 +193,7 @@ class AuthBloc extends Bloc { _listenToAuthStateChanges(); } catch (e, s) { final error = 'Failed to restore existing wallet ${event.wallet.name}'; - log(error, isError: true, trace: s, path: 'auth_bloc -> onRestore') - .ignore(); + _log.shout(error, e, s); emit(AuthBlocState.error(error)); await _authChangesSubscription?.cancel(); } @@ -213,7 +208,7 @@ class AuthBloc extends Bloc { .any((KdfUser user) => user.walletId.name == wallet.name); if (walletExists) { add(AuthSignInRequested(wallet: wallet, password: password)); - log('Wallet ${wallet.name} already exist, attempting sign-in').ignore(); + _log.warning('Wallet ${wallet.name} already exist, attempting sign-in'); return true; } @@ -239,18 +234,22 @@ class AuthBloc extends Bloc { AuthWalletDownloadRequested event, Emitter emit, ) async { - final Wallet? wallet = (await _kdfSdk.auth.currentUser)?.wallet; - if (wallet == null) return; + try { + final Wallet? wallet = (await _kdfSdk.auth.currentUser)?.wallet; + if (wallet == null) return; - await _walletsRepository.downloadEncryptedWallet(wallet, event.password); + await _walletsRepository.downloadEncryptedWallet(wallet, event.password); - await _kdfSdk.confirmSeedBackup(); - emit( - AuthBlocState( - mode: AuthorizeMode.logIn, - currentUser: await _kdfSdk.auth.currentUser, - ), - ); + await _kdfSdk.confirmSeedBackup(); + emit( + AuthBlocState( + mode: AuthorizeMode.logIn, + currentUser: await _kdfSdk.auth.currentUser, + ), + ); + } catch (e, s) { + _log.shout('Failed to download wallet data', e, s); + } } void _listenToAuthStateChanges() { diff --git a/lib/bloc/cex_market_data/mockup/generator.dart b/lib/bloc/cex_market_data/mockup/generator.dart index fdd9cfe1a5..b80a94bec0 100644 --- a/lib/bloc/cex_market_data/mockup/generator.dart +++ b/lib/bloc/cex_market_data/mockup/generator.dart @@ -11,13 +11,12 @@ final _supportedCoinsCache = >{}; final _transactionsCache = >>{}; class DemoDataCache { - final DemoDataGenerator _generator; - DemoDataCache(this._generator); DemoDataCache.withDefaults() : _generator = DemoDataGenerator( BinanceRepository(binanceProvider: const BinanceProvider()), ); + final DemoDataGenerator _generator; Future> supportedCoinsDemoData() async { const cacheKey = 'supportedCoins'; @@ -27,7 +26,7 @@ class DemoDataCache { final String response = await rootBundle.loadString('assets/debug/demo_trade_data.json'); - final Map data = json.decode(response); + final data = json.decode(response) as Map; final result = (data['profit'] as Map).keys.toList(); _supportedCoinsCache[cacheKey] = result; return result; diff --git a/lib/bloc/cex_market_data/mockup/mock_portfolio_growth_repository.dart b/lib/bloc/cex_market_data/mockup/mock_portfolio_growth_repository.dart index 0a506d2b78..f27981068c 100644 --- a/lib/bloc/cex_market_data/mockup/mock_portfolio_growth_repository.dart +++ b/lib/bloc/cex_market_data/mockup/mock_portfolio_growth_repository.dart @@ -1,4 +1,3 @@ -import 'package:http/http.dart'; import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; import 'package:komodo_persistence_layer/komodo_persistence_layer.dart'; import 'package:web_dex/bloc/cex_market_data/mockup/generator.dart'; @@ -7,37 +6,33 @@ import 'package:web_dex/bloc/cex_market_data/mockup/performance_mode.dart'; import 'package:web_dex/bloc/cex_market_data/models/graph_cache.dart'; import 'package:web_dex/bloc/cex_market_data/models/graph_type.dart'; import 'package:web_dex/bloc/cex_market_data/portfolio_growth/portfolio_growth_repository.dart'; -import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; -import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; class MockPortfolioGrowthRepository extends PortfolioGrowthRepository { - final PerformanceMode performanceMode; - MockPortfolioGrowthRepository({ required super.cexRepository, required super.transactionHistoryRepo, required super.cacheProvider, required this.performanceMode, required super.coinsRepository, + required super.sdk, }); MockPortfolioGrowthRepository.withDefaults({ required this.performanceMode, - required CoinsRepo coinsRepository, - required Mm2Api mm2Api, + required super.coinsRepository, + required super.sdk, }) : super( cexRepository: BinanceRepository( binanceProvider: const BinanceProvider(), ), transactionHistoryRepo: MockTransactionHistoryRepo( - api: mm2Api, - client: Client(), performanceMode: performanceMode, demoDataGenerator: DemoDataCache.withDefaults(), ), cacheProvider: HiveLazyBoxProvider( name: GraphType.balanceGrowth.tableName, ), - coinsRepository: coinsRepository, ); + + final PerformanceMode performanceMode; } diff --git a/lib/bloc/cex_market_data/mockup/mock_transaction_history_repository.dart b/lib/bloc/cex_market_data/mockup/mock_transaction_history_repository.dart index 0763a0678f..0a06270362 100644 --- a/lib/bloc/cex_market_data/mockup/mock_transaction_history_repository.dart +++ b/lib/bloc/cex_market_data/mockup/mock_transaction_history_repository.dart @@ -1,20 +1,17 @@ -import 'package:http/http.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:web_dex/bloc/cex_market_data/mockup/generator.dart'; import 'package:web_dex/bloc/cex_market_data/mockup/performance_mode.dart'; import 'package:web_dex/bloc/transaction_history/transaction_history_repo.dart'; -import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; class MockTransactionHistoryRepo implements TransactionHistoryRepo { - final PerformanceMode performanceMode; - final DemoDataCache demoDataGenerator; - MockTransactionHistoryRepo({ - required Mm2Api api, - required Client client, required this.performanceMode, required this.demoDataGenerator, }); + + final PerformanceMode performanceMode; + final DemoDataCache demoDataGenerator; + @override Future> fetch(AssetId assetId) { return demoDataGenerator.loadTransactionsDemoData( diff --git a/lib/bloc/cex_market_data/models/adapters/graph_cache_adapter.dart b/lib/bloc/cex_market_data/models/adapters/graph_cache_adapter.dart index c9faeebaf1..adaca984dc 100644 --- a/lib/bloc/cex_market_data/models/adapters/graph_cache_adapter.dart +++ b/lib/bloc/cex_market_data/models/adapters/graph_cache_adapter.dart @@ -21,13 +21,16 @@ class GraphCacheAdapter extends TypeAdapter { graph: (fields[3] as List).cast>(), graphType: GraphType.fromName(fields[4] as String), walletId: fields[5] as String, + // Load conditionally, and set a default value for backwards compatibility + // with existing data + isHdWallet: fields.containsKey(6) ? fields[6] as bool : false, ); } @override void write(BinaryWriter writer, GraphCache obj) { writer - ..writeByte(6) + ..writeByte(7) ..writeByte(0) ..write(obj.coinId) ..writeByte(1) @@ -39,7 +42,9 @@ class GraphCacheAdapter extends TypeAdapter { ..writeByte(4) ..write(obj.graphType.name) ..writeByte(5) - ..write(obj.walletId); + ..write(obj.walletId) + ..writeByte(6) + ..write(obj.isHdWallet); } @override diff --git a/lib/bloc/cex_market_data/models/graph_cache.dart b/lib/bloc/cex_market_data/models/graph_cache.dart index 4029d3d4b5..1a39023532 100644 --- a/lib/bloc/cex_market_data/models/graph_cache.dart +++ b/lib/bloc/cex_market_data/models/graph_cache.dart @@ -1,4 +1,5 @@ import 'package:equatable/equatable.dart'; +import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; import 'package:komodo_persistence_layer/komodo_persistence_layer.dart'; import 'package:web_dex/bloc/cex_market_data/charts.dart'; import 'package:web_dex/bloc/cex_market_data/models/graph_type.dart'; @@ -13,26 +14,30 @@ class GraphCache extends Equatable implements ObjectWithPrimaryKey { required this.graph, required this.graphType, required this.walletId, + required this.isHdWallet, }); factory GraphCache.fromJson(Map json) { return GraphCache( - coinId: json['coinId'], - fiatCoinId: json['fiatCoinId'], - lastUpdated: DateTime.parse(json['lastUpdated']), - graph: List.from(json['portfolioGrowthGraphs']), - graphType: json['graphType'], - walletId: json['walletId'], + coinId: json.value('coinId'), + fiatCoinId: json.value('fiatCoinId'), + lastUpdated: DateTime.parse(json.value('lastUpdated')), + graph: List.from(json.value>('portfolioGrowthGraphs')), + graphType: json.value('graphType'), + walletId: json.value('walletId'), + // Explicitly set the default value to false for backwards compatibility. + isHdWallet: json.valueOrNull('isHdWallet') ?? false, ); } - static String getPrimaryKey( - String coinId, - String fiatCoinId, - GraphType graphType, - String walletId, - ) => - '$coinId-$fiatCoinId-${graphType.name}-$walletId'; + static String getPrimaryKey({ + required String coinId, + required String fiatCoinId, + required GraphType graphType, + required String walletId, + required bool isHdWallet, + }) => + '$coinId-$fiatCoinId-${graphType.name}-$walletId-$isHdWallet'; /// The komodo coin abbreviation from the coins repository (e.g. BTC, etc.). final String coinId; @@ -52,6 +57,10 @@ class GraphCache extends Equatable implements ObjectWithPrimaryKey { /// The wallet ID. final String walletId; + /// The flag indicating if the wallet is an HD wallet. A wallet with + /// [walletId] can be either a regular wallet or an HD wallet. + final bool isHdWallet; + Map toJson() { return { 'coinId': coinId, @@ -70,6 +79,7 @@ class GraphCache extends Equatable implements ObjectWithPrimaryKey { ChartData? portfolioGrowthGraphs, GraphType? graphType, String? walletId, + bool? isHdWallet, }) { return GraphCache( coinId: coinId ?? this.coinId, @@ -78,6 +88,7 @@ class GraphCache extends Equatable implements ObjectWithPrimaryKey { graph: portfolioGrowthGraphs ?? graph, graphType: graphType ?? this.graphType, walletId: walletId ?? this.walletId, + isHdWallet: isHdWallet ?? this.isHdWallet, ); } @@ -92,6 +103,11 @@ class GraphCache extends Equatable implements ObjectWithPrimaryKey { ]; @override - String get primaryKey => - getPrimaryKey(coinId, fiatCoinId, graphType, walletId); + String get primaryKey => GraphCache.getPrimaryKey( + coinId: coinId, + fiatCoinId: fiatCoinId, + graphType: graphType, + walletId: walletId, + isHdWallet: isHdWallet, + ); } diff --git a/lib/bloc/cex_market_data/portfolio_growth/cache_miss_exception.dart b/lib/bloc/cex_market_data/portfolio_growth/cache_miss_exception.dart new file mode 100644 index 0000000000..5ea1574e7a --- /dev/null +++ b/lib/bloc/cex_market_data/portfolio_growth/cache_miss_exception.dart @@ -0,0 +1,11 @@ +/// Exception thrown when a cache miss occurs in the portfolio growth repository. +class CacheMissException implements Exception { + /// Creates a new [CacheMissException] with the provided cache key. + const CacheMissException(this.cacheKey); + + /// The cache key that was not found. + final String cacheKey; + + @override + String toString() => 'Cache miss for key: $cacheKey'; +} 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 6b05f2a775..08ab9cf077 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 @@ -3,13 +3,14 @@ import 'dart:async'; import 'package:bloc/bloc.dart'; import 'package:bloc_concurrency/bloc_concurrency.dart'; 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/portfolio_growth/portfolio_growth_repository.dart'; -import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; +import 'package:web_dex/bloc/cex_market_data/sdk_auth_activation_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/utils.dart'; part 'portfolio_growth_event.dart'; part 'portfolio_growth_state.dart'; @@ -18,7 +19,7 @@ class PortfolioGrowthBloc extends Bloc { PortfolioGrowthBloc({ required this.portfolioGrowthRepository, - required this.coinsRepository, + required this.sdk, }) : 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 @@ -35,7 +36,8 @@ class PortfolioGrowthBloc } final PortfolioGrowthRepository portfolioGrowthRepository; - final CoinsRepo coinsRepository; + final KomodoDefiSdk sdk; + final _log = Logger('PortfolioGrowthBloc'); void _onClearPortfolioGrowth( PortfolioGrowthClearRequested event, @@ -48,7 +50,7 @@ class PortfolioGrowthBloc PortfolioGrowthPeriodChanged event, Emitter emit, ) { - if (state is GrowthChartLoadFailure) { + if (state is! PortfolioGrowthChartLoadSuccess) { emit( GrowthChartLoadFailure( error: (state as GrowthChartLoadFailure).error, @@ -84,51 +86,48 @@ class PortfolioGrowthBloc await _loadChart(coins, event, useCache: true) .then(emit.call) - .catchError((e, _) { - if (state is! PortfolioGrowthChartLoadSuccess) { - emit( - GrowthChartLoadFailure( - error: TextError(error: e.toString()), - selectedPeriod: event.selectedPeriod, - ), - ); - } + .catchError((Object error, StackTrace stackTrace) { + const errorMessage = 'Failed to load cached chart'; + _log.warning(errorMessage, error, stackTrace); + // ignore cached errors, as the periodic refresh attempts should recover + // at the cost of a longer first loading time. }); + // 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); + // Only remove inactivate/activating coins after an attempt to load the // cached chart, as the cached chart may contain inactive coins. final activeCoins = await _removeInactiveCoins(coins); if (activeCoins.isNotEmpty) { await _loadChart(activeCoins, event, useCache: false) .then(emit.call) - .catchError((_, __) { - // Ignore un-cached errors, as a transaction loading exception should not - // make the graph disappear with a load failure emit, as the cached data - // is already displayed. The periodic updates will still try to fetch the - // data and update the graph. + .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 (e, s) { - log( - 'Failed to load portfolio growth: $e', - isError: true, - trace: s, - path: 'portfolio_growth_bloc => _onLoadPortfoliowGrowth', - ); + } 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. } await emit.forEach( - Stream.periodic(event.updateFrequency) - .asyncMap((_) async => await _fetchPortfolioGrowthChart(event)), + // computation is omitted, so null-valued events are emitted on a set + // interval. + Stream.periodic(event.updateFrequency) + .asyncMap((_) async => _fetchPortfolioGrowthChart(event)), onData: (data) => _handlePortfolioGrowthUpdate(data, event.selectedPeriod), - onError: (e, _) { - log( - 'Failed to load portfolio growth: $e', - isError: true, - ); + onError: (error, stackTrace) { + _log.shout('Failed to load portfolio growth', error, stackTrace); return GrowthChartLoadFailure( - error: TextError(error: e.toString()), + error: TextError(error: 'Failed to load portfolio growth'), selectedPeriod: event.selectedPeriod, ); }, @@ -149,19 +148,6 @@ class PortfolioGrowthBloc return coins; } - Future> _removeInactiveCoins(List coins) async { - final List coinsCopy = List.from(coins); - for (final coin in coins) { - final updatedCoin = await coinsRepository.getEnabledCoin(coin.abbr); - if (updatedCoin == null || - updatedCoin.isActivating || - !updatedCoin.isActive) { - coinsCopy.remove(coin); - } - } - return coinsCopy; - } - Future _loadChart( List coins, PortfolioGrowthLoadRequested event, { @@ -198,17 +184,24 @@ class PortfolioGrowthBloc walletId: event.walletId, useCache: false, ); - } catch (e, s) { - log( - 'Empty growth chart on periodic update: $e', - isError: true, - trace: s, - path: 'PortfolioGrowthBloc', - ).ignore(); + } catch (error, stackTrace) { + _log.shout('Empty growth chart on periodic update', error, stackTrace); return ChartData.empty(); } } + Future> _removeInactiveCoins(List coins) async { + final coinsCopy = List.of(coins); + final activeCoins = await sdk.assets.getActivatedAssets(); + final activeCoinsMap = activeCoins.map((e) => e.id).toSet(); + for (final coin in coins) { + if (!activeCoinsMap.contains(coin.id)) { + coinsCopy.remove(coin); + } + } + return coinsCopy; + } + PortfolioGrowthState _handlePortfolioGrowthUpdate( ChartData growthChart, Duration selectedPeriod, diff --git a/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_repository.dart b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_repository.dart index fd6d4c8c38..8a60d1002e 100644 --- a/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_repository.dart +++ b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_repository.dart @@ -1,18 +1,20 @@ -import 'dart:math'; +import 'dart:math' show Point; import 'package:hive/hive.dart'; import 'package:komodo_cex_market_data/komodo_cex_market_data.dart' as cex; import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; +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: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'; import 'package:web_dex/bloc/cex_market_data/models/graph_type.dart'; 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/mm2/mm2_api/mm2_api.dart'; import 'package:web_dex/model/coin.dart'; /// A repository for fetching the growth chart for the portfolio and coins. @@ -23,10 +25,12 @@ class PortfolioGrowthRepository { required TransactionHistoryRepo transactionHistoryRepo, required PersistenceProvider cacheProvider, required CoinsRepo coinsRepository, + required KomodoDefiSdk sdk, }) : _transactionHistoryRepository = transactionHistoryRepo, _cexRepository = cexRepository, _graphCache = cacheProvider, - _coinsRepository = coinsRepository; + _coinsRepository = coinsRepository, + _sdk = sdk; /// Create a new instance of the repository with default dependencies. /// The default dependencies are the [BinanceRepository] and the @@ -35,14 +39,14 @@ class PortfolioGrowthRepository { required TransactionHistoryRepo transactionHistoryRepo, required cex.CexRepository cexRepository, required CoinsRepo coinsRepository, - required Mm2Api mm2Api, + required KomodoDefiSdk sdk, PerformanceMode? demoMode, }) { if (demoMode != null) { return MockPortfolioGrowthRepository.withDefaults( performanceMode: demoMode, coinsRepository: coinsRepository, - mm2Api: mm2Api, + sdk: sdk, ); } @@ -53,6 +57,7 @@ class PortfolioGrowthRepository { name: GraphType.balanceGrowth.tableName, ), coinsRepository: coinsRepository, + sdk: sdk, ); } @@ -66,6 +71,9 @@ class PortfolioGrowthRepository { final PersistenceProvider _graphCache; final CoinsRepo _coinsRepository; + final KomodoDefiSdk _sdk; + + final _log = Logger('PortfolioGrowthRepository'); static Future ensureInitialized() async { Hive @@ -76,7 +84,7 @@ class PortfolioGrowthRepository { /// Get the growth chart for a coin based on the transactions /// and the spot price of the coin in the fiat currency. /// - /// NOTE: On a cache miss, an [Exception] is thrown. The assumption is that + /// NOTE: On a cache miss, a [CacheMissException] is thrown. The assumption is that /// the function is called with useCache set to false to fetch the /// transactions again. /// NOTE: If the transactions are empty, an empty chart is stored in the @@ -89,13 +97,11 @@ class PortfolioGrowthRepository { /// [endAt] is the end time of the chart. /// [useCache] is a flag to indicate whether to use the cache when fetching /// the chart. If set to `true`, the chart is fetched from the cache if it - /// exists, otherwise an [Exception] is thrown. + /// exists, otherwise a [CacheMissException] is thrown. /// /// Returns the growth [ChartData] for the coin ([List] of [Point]). Future getCoinGrowthChart( AssetId coinId, { - // avoid the possibility of accidentally swapping the order of these - // required parameters by using named parameters required String fiatCoinId, required String walletId, DateTime? startAt, @@ -103,40 +109,77 @@ class PortfolioGrowthRepository { bool useCache = true, bool ignoreTransactionFetchErrors = true, }) async { + final methodStopwatch = Stopwatch()..start(); + _log.fine('Getting growth chart for coin: ${coinId.id}'); + + final currentUser = await _sdk.auth.currentUser; + if (currentUser == null) { + _log.warning('User is not logged in when fetching growth chart'); + throw Exception('User is not logged in'); + } + if (useCache) { + final cacheStopwatch = Stopwatch()..start(); final String compoundKey = GraphCache.getPrimaryKey( - coinId.id, - fiatCoinId, - GraphType.balanceGrowth, - walletId, + coinId: coinId.id, + fiatCoinId: fiatCoinId, + graphType: GraphType.balanceGrowth, + walletId: walletId, + isHdWallet: currentUser.isHd, ); final GraphCache? cachedGraph = await _graphCache.get(compoundKey); final cacheExists = cachedGraph != null; + cacheStopwatch.stop(); + if (cacheExists) { + _log.fine( + 'Cache hit for ${coinId.id}: ${cacheStopwatch.elapsedMilliseconds}ms', + ); + methodStopwatch.stop(); + _log.fine( + 'getCoinGrowthChart completed in ' + '${methodStopwatch.elapsedMilliseconds}ms (cached)', + ); return cachedGraph.graph; } else { - throw Exception('Cache miss for $compoundKey'); + _log.fine( + 'Cache miss ${coinId.id}: ${cacheStopwatch.elapsedMilliseconds}ms', + ); + throw CacheMissException(compoundKey); } } - // TODO: Refactor referenced coinsBloc method to a repository. - // NB: Even though the class is called [CoinsBloc], it is not a Bloc. final Coin coin = _coinsRepository.getCoinFromId(coinId)!; + + final txStopwatch = Stopwatch()..start(); + _log.fine('Fetching transactions for ${coin.id}'); final List transactions = await _transactionHistoryRepository .fetchCompletedTransactions(coin.id) .then((value) => value.toList()) .catchError((Object e) { + txStopwatch.stop(); + _log.warning( + 'Error fetching transactions for ${coin.id} ' + 'in ${txStopwatch.elapsedMilliseconds}ms: $e', + ); if (ignoreTransactionFetchErrors) { return List.empty(); } else { throw e; } }); + txStopwatch.stop(); + _log.fine( + 'Fetched ${transactions.length} transactions for ${coin.id} ' + 'in ${txStopwatch.elapsedMilliseconds}ms', + ); if (transactions.isEmpty) { + _log.fine('No transactions found for ${coin.id}, caching empty chart'); // Insert an empty chart into the cache to avoid fetching transactions // again for each invocation. The assumption is that this function is // called later with useCache set to false to fetch the transactions again + final cacheInsertStopwatch = Stopwatch()..start(); await _graphCache.insert( GraphCache( coinId: coinId.id, @@ -145,8 +188,20 @@ class PortfolioGrowthRepository { graph: List.empty(), graphType: GraphType.balanceGrowth, walletId: walletId, + isHdWallet: currentUser.isHd, ), ); + cacheInsertStopwatch.stop(); + _log.fine( + 'Cached empty chart for ${coin.id} ' + 'in ${cacheInsertStopwatch.elapsedMilliseconds}ms', + ); + + methodStopwatch.stop(); + _log.fine( + 'getCoinGrowthChart for ${coin.id.id} completed in ' + '${methodStopwatch.elapsedMilliseconds}ms (empty)', + ); return List.empty(); } @@ -155,16 +210,23 @@ class PortfolioGrowthRepository { startAt ??= transactions.first.timestamp; endAt ??= DateTime.now(); - final String baseCoinId = coin.abbr.split('-').first; + final String baseCoinId = coin.id.symbol.configSymbol.toUpperCase(); final cex.GraphInterval interval = _getOhlcInterval( startAt, endDate: endAt, ); + _log.fine( + 'Fetching OHLC data for $baseCoinId/$fiatCoinId ' + 'with interval: $interval', + ); + + final ohlcStopwatch = Stopwatch()..start(); cex.CoinOhlc ohlcData; // if the base coin is the same as the fiat coin, return a chart with a // constant value of 1.0 if (baseCoinId.toLowerCase() == fiatCoinId.toLowerCase()) { + _log.fine('Using constant price for fiat coin: $baseCoinId'); ohlcData = cex.CoinOhlc.fromConstantPrice( startAt: startAt, endAt: endAt, @@ -178,20 +240,36 @@ class PortfolioGrowthRepository { endAt: endAt, ); } + ohlcStopwatch.stop(); + _log.fine( + 'Fetched ${ohlcData.ohlc.length} OHLC data points ' + 'in ${ohlcStopwatch.elapsedMilliseconds}ms', + ); final List> portfolowGrowthChart = _mergeTransactionsWithOhlc(ohlcData, transactions); - + final cacheInsertStopwatch = Stopwatch()..start(); await _graphCache.insert( GraphCache( - coinId: coin.abbr, + coinId: coin.id.id, fiatCoinId: fiatCoinId, lastUpdated: DateTime.now(), graph: portfolowGrowthChart, graphType: GraphType.balanceGrowth, walletId: walletId, + isHdWallet: currentUser.isHd, ), ); + cacheInsertStopwatch.stop(); + _log.fine( + 'Cached chart for ${coin.id} in ${cacheInsertStopwatch.elapsedMilliseconds}ms', + ); + + methodStopwatch.stop(); + _log.fine( + 'getCoinGrowthChart completed in ${methodStopwatch.elapsedMilliseconds}ms ' + 'with ${portfolowGrowthChart.length} data points', + ); return portfolowGrowthChart; } @@ -224,46 +302,84 @@ class PortfolioGrowthRepository { DateTime? endAt, bool ignoreTransactionFetchErrors = true, }) async { + final methodStopwatch = Stopwatch()..start(); + _log.fine( + 'Getting portfolio growth chart for ${coins.length} coins, ' + 'useCache: $useCache', + ); + if (coins.isEmpty) { + _log.warning('Empty coins list provided to getPortfolioGrowthChart'); assert(coins.isNotEmpty, 'The list of coins should not empty.'); return ChartData.empty(); } + final parallelFetchStopwatch = Stopwatch()..start(); + // this is safe because increments are atomic operations, and dart is + // single-threaded, so no concern over race conditions. + int successCount = 0; + int errorCount = 0; + final chartDataFutures = coins.map((coin) async { try { - return await getCoinGrowthChart( + final chartData = await getCoinGrowthChart( coin.id, fiatCoinId: fiatCoinId, useCache: useCache, walletId: walletId, ignoreTransactionFetchErrors: ignoreTransactionFetchErrors, ); + successCount++; + return chartData; } on TransactionFetchException { + errorCount++; + _log.warning('Failed to fetch transactions for ${coin.id} '); if (ignoreTransactionFetchErrors) { return Future.value(ChartData.empty()); } else { rethrow; } - } on Exception { - // Exception primarily thrown for cache misses - // TODO: create a custom exception for cache misses to avoid catching - // this broad exception type + } on CacheMissException { + errorCount++; + _log.fine('Cache miss for ${coin.id}'); + return Future.value(ChartData.empty()); + } on Exception catch (error, stackTrace) { + errorCount++; + _log.severe('Error fetching chart for ${coin.id}', error, stackTrace); return Future.value(ChartData.empty()); } }); final charts = await Future.wait(chartDataFutures); + parallelFetchStopwatch.stop(); + + _log.fine( + 'Parallel fetch completed in ${parallelFetchStopwatch.elapsedMilliseconds}ms, ' + 'success: $successCount, errors: $errorCount', + ); charts.removeWhere((element) => element.isEmpty); if (charts.isEmpty) { + _log.warning( + 'getPortfolioGrowthChart: No valid charts found after filtering ' + 'empty charts in ${methodStopwatch.elapsedMilliseconds}ms'); return ChartData.empty(); } final mergedChart = Charts.merge(charts, mergeType: MergeType.leftJoin); + // Add the current USD balance to the end of the chart to ensure that the // chart matches the current prices and ends at the current time. final double totalUsdBalance = coins.fold(0, (prev, coin) => prev + (coin.usdBalance ?? 0)); if (totalUsdBalance <= 0) { + _log.fine( + 'Total USD balance is zero or negative, skipping balance point addition', + ); + methodStopwatch.stop(); + _log.fine( + 'getPortfolioGrowthChart completed in ${methodStopwatch.elapsedMilliseconds}ms ' + 'with ${mergedChart.length} data points', + ); return mergedChart; } @@ -274,15 +390,42 @@ class PortfolioGrowthRepository { totalUsdBalance, ), ); + _log.fine( + 'Added current balance point: $totalUsdBalance USD at ' + '${currentDate.toIso8601String()}', + ); - return mergedChart.filterDomain(startAt: startAt, endAt: endAt); + if (startAt != null || endAt != null) { + _log.fine( + 'Filtering chart domain: startAt: ${startAt?.toIso8601String()}, ' + 'endAt: ${endAt?.toIso8601String()}', + ); + } + + final filteredChart = + mergedChart.filterDomain(startAt: startAt, endAt: endAt); + + methodStopwatch.stop(); + _log.fine( + 'getPortfolioGrowthChart completed in ${methodStopwatch.elapsedMilliseconds}ms ' + 'with ${filteredChart.length} data points', + ); + + return filteredChart; } ChartData _mergeTransactionsWithOhlc( cex.CoinOhlc ohlcData, List transactions, ) { + final stopwatch = Stopwatch()..start(); + _log.fine( + 'Merging ${transactions.length} transactions with ' + '${ohlcData.ohlc.length} OHLC data points', + ); + if (transactions.isEmpty || ohlcData.ohlc.isEmpty) { + _log.warning('Empty transactions or OHLC data, returning empty chart'); return List.empty(); } @@ -296,6 +439,12 @@ class PortfolioGrowthRepository { final portfolowGrowthChart = Charts.mergeTransactionsWithPortfolioOHLC(transactions, spotValues); + stopwatch.stop(); + _log.fine( + 'Merged transactions with OHLC in ${stopwatch.elapsedMilliseconds}ms, ' + 'resulting in ${portfolowGrowthChart.length} data points', + ); + return portfolowGrowthChart; } @@ -315,9 +464,8 @@ class PortfolioGrowthRepository { bool allowFiatAsBase = true, }) async { final Coin coin = _coinsRepository.getCoinFromId(coinId)!; - final supportedCoins = await _cexRepository.getCoinList(); - final coinTicker = coin.abbr.split('-').firstOrNull?.toUpperCase() ?? ''; + final coinTicker = coin.id.symbol.configSymbol.toUpperCase(); // Allow fiat coins through, as they are represented by a constant value, // 1, in the repository layer and are not supported by the CEX API if (allowFiatAsBase && coinTicker == fiatCoinId.toUpperCase()) { diff --git a/lib/bloc/cex_market_data/profit_loss/demo_profit_loss_repository.dart b/lib/bloc/cex_market_data/profit_loss/demo_profit_loss_repository.dart index ba6af9ccb3..e223f735f8 100644 --- a/lib/bloc/cex_market_data/profit_loss/demo_profit_loss_repository.dart +++ b/lib/bloc/cex_market_data/profit_loss/demo_profit_loss_repository.dart @@ -1,5 +1,5 @@ -import 'package:http/http.dart'; import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; +import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_persistence_layer/komodo_persistence_layer.dart'; import 'package:web_dex/bloc/cex_market_data/mockup/generator.dart'; import 'package:web_dex/bloc/cex_market_data/mockup/mock_transaction_history_repository.dart'; @@ -7,25 +7,20 @@ import 'package:web_dex/bloc/cex_market_data/mockup/performance_mode.dart'; import 'package:web_dex/bloc/cex_market_data/profit_loss/models/profit_loss_cache.dart'; import 'package:web_dex/bloc/cex_market_data/profit_loss/profit_loss_calculator.dart'; import 'package:web_dex/bloc/cex_market_data/profit_loss/profit_loss_repository.dart'; -import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; -import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; class MockProfitLossRepository extends ProfitLossRepository { - final PerformanceMode performanceMode; - MockProfitLossRepository({ required this.performanceMode, required super.transactionHistoryRepo, required super.cexRepository, required super.profitLossCacheProvider, required super.profitLossCalculator, - required super.coinsRepository, + required super.sdk, }); factory MockProfitLossRepository.withDefaults({ required PerformanceMode performanceMode, - required CoinsRepo coinsRepository, - required Mm2Api mm2Api, + required KomodoDefiSdk sdk, String cacheTableName = 'mock_profit_loss', }) { return MockProfitLossRepository( @@ -36,8 +31,6 @@ class MockProfitLossRepository extends ProfitLossRepository { ), performanceMode: performanceMode, transactionHistoryRepo: MockTransactionHistoryRepo( - api: mm2Api, - client: Client(), performanceMode: performanceMode, demoDataGenerator: DemoDataCache.withDefaults(), ), @@ -46,7 +39,9 @@ class MockProfitLossRepository extends ProfitLossRepository { binanceProvider: const BinanceProvider(), ), ), - coinsRepository: coinsRepository, + sdk: sdk, ); } + + final PerformanceMode performanceMode; } diff --git a/lib/bloc/cex_market_data/profit_loss/models/adapters/profit_loss_adapter.dart b/lib/bloc/cex_market_data/profit_loss/models/adapters/profit_loss_adapter.dart index 16ae889139..ad679f415d 100644 --- a/lib/bloc/cex_market_data/profit_loss/models/adapters/profit_loss_adapter.dart +++ b/lib/bloc/cex_market_data/profit_loss/models/adapters/profit_loss_adapter.dart @@ -1,7 +1,6 @@ import 'package:hive/hive.dart'; - -import '../fiat_value.dart'; -import '../profit_loss.dart'; +import 'package:web_dex/bloc/cex_market_data/profit_loss/models/fiat_value.dart'; +import 'package:web_dex/bloc/cex_market_data/profit_loss/models/profit_loss.dart'; class ProfitLossAdapter extends TypeAdapter { @override diff --git a/lib/bloc/cex_market_data/profit_loss/models/adapters/profit_loss_cache_adapter.dart b/lib/bloc/cex_market_data/profit_loss/models/adapters/profit_loss_cache_adapter.dart index 4444b50efc..1153a2a0d4 100644 --- a/lib/bloc/cex_market_data/profit_loss/models/adapters/profit_loss_cache_adapter.dart +++ b/lib/bloc/cex_market_data/profit_loss/models/adapters/profit_loss_cache_adapter.dart @@ -1,6 +1,5 @@ import 'package:hive/hive.dart'; - -import '../profit_loss_cache.dart'; +import 'package:web_dex/bloc/cex_market_data/profit_loss/models/profit_loss_cache.dart'; class ProfitLossCacheAdapter extends TypeAdapter { @override @@ -19,13 +18,16 @@ class ProfitLossCacheAdapter extends TypeAdapter { lastUpdated: fields[2] as DateTime, profitLosses: (fields[3] as List).cast(), walletId: fields[4] as String, + // Load conditionally, and set a default value for backwards compatibility + // with existing data + isHdWallet: fields.containsKey(5) ? fields[5] as bool : false, ); } @override void write(BinaryWriter writer, ProfitLossCache obj) { writer - ..writeByte(5) + ..writeByte(6) ..writeByte(0) ..write(obj.coinId) ..writeByte(1) @@ -35,7 +37,9 @@ class ProfitLossCacheAdapter extends TypeAdapter { ..writeByte(3) ..write(obj.profitLosses) ..writeByte(4) - ..write(obj.walletId); + ..write(obj.walletId) + ..writeByte(5) + ..write(obj.isHdWallet); } @override diff --git a/lib/bloc/cex_market_data/profit_loss/models/profit_loss_cache.dart b/lib/bloc/cex_market_data/profit_loss/models/profit_loss_cache.dart index e9e9d2ed78..0ee9c401b5 100644 --- a/lib/bloc/cex_market_data/profit_loss/models/profit_loss_cache.dart +++ b/lib/bloc/cex_market_data/profit_loss/models/profit_loss_cache.dart @@ -13,6 +13,7 @@ class ProfitLossCache extends Equatable required this.lastUpdated, required this.profitLosses, required this.walletId, + required this.isHdWallet, }); /// The komodo coin abbreviation from the coins repository (e.g. BTC, KMD, etc.). @@ -26,21 +27,32 @@ class ProfitLossCache extends Equatable /// The wallet ID associated with the profit/loss data. final String walletId; - /// The timestamp of the last update in seconds since epoch. (e.g. [DateTime.now().millisecondsSinceEpoch ~/ 1000]) + /// Whether the wallet is an HD wallet. Same [walletId] can be used for both + /// HD and non-HD wallets, but the profit/loss data will be different. + final bool isHdWallet; + + /// The timestamp of the last update in seconds since epoch. + /// (e.g. [DateTime.now().millisecondsSinceEpoch ~/ 1000]) final DateTime lastUpdated; /// The list of [ProfitLoss] data. final List profitLosses; @override - get primaryKey => getPrimaryKey(coinId, fiatCoinId, walletId); - - static String getPrimaryKey( - String coinId, - String fiatCurrency, - String walletId, - ) => - '$coinId-$fiatCurrency-$walletId'; + String get primaryKey => getPrimaryKey( + coinId: coinId, + fiatCurrency: fiatCoinId, + walletId: walletId, + isHdWallet: isHdWallet, + ); + + static String getPrimaryKey({ + required String coinId, + required String fiatCurrency, + required String walletId, + required bool isHdWallet, + }) => + '$coinId-$fiatCurrency-$walletId-$isHdWallet'; @override List get props => [coinId, fiatCoinId, lastUpdated, profitLosses]; 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 f6b51274cc..f156b9d40e 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 @@ -1,24 +1,24 @@ import 'dart:async'; -import 'dart:math'; +import 'dart:math' show Point; import 'package:bloc/bloc.dart'; import 'package:bloc_concurrency/bloc_concurrency.dart'; 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/profit_loss/profit_loss_repository.dart'; +import 'package:web_dex/bloc/cex_market_data/sdk_auth_activation_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/utils.dart' as logger; part 'profit_loss_event.dart'; part 'profit_loss_state.dart'; class ProfitLossBloc extends Bloc { - ProfitLossBloc({ - required ProfitLossRepository profitLossRepository, - }) : _profitLossRepository = profitLossRepository, - super(const ProfitLossInitial()) { + ProfitLossBloc(this._profitLossRepository, this._sdk) + : 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). @@ -26,12 +26,14 @@ class ProfitLossBloc extends Bloc { _onLoadPortfolioProfitLoss, transformer: restartable(), ); - on(_onPortfolioPeriodChanged); on(_onClearPortfolioProfitLoss); } final ProfitLossRepository _profitLossRepository; + final KomodoDefiSdk _sdk; + + final _log = Logger('ProfitLossBloc'); void _onClearPortfolioProfitLoss( ProfitLossPortfolioChartClearRequested event, @@ -44,78 +46,57 @@ class ProfitLossBloc extends Bloc { ProfitLossPortfolioChartLoadRequested event, Emitter emit, ) async { - List coins = await _removeUnsupportedCons(event); - // 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) { - return emit( - PortfolioProfitLossChartUnsupported( - selectedPeriod: event.selectedPeriod, - ), - ); - } - - await _getProfitLossChart(event, coins, useCache: true) - .then(emit.call) - .catchError((e, _) { - logger.log('Failed to load portfolio profit/loss: $e', isError: true); - if (state is! PortfolioProfitLossChartLoadSuccess) { - emit( - ProfitLossLoadFailure( - error: TextError(error: 'Failed to load portfolio profit/loss: $e'), + try { + final supportedCoins = + await _removeUnsupportedCons(event.coins, event.fiatCoinId); + // 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) { + return emit( + PortfolioProfitLossChartUnsupported( selectedPeriod: event.selectedPeriod, ), ); } - }); - // Fetch the un-cached version of the chart to update the cache. - coins = await _removeUnsupportedCons(event, allowInactiveCoins: false); - if (coins.isNotEmpty) { - await _getProfitLossChart(event, coins, useCache: false) + await _getProfitLossChart(event, supportedCoins, useCache: true) .then(emit.call) - .catchError((e, _) { - // Ignore un-cached errors, as a transaction loading exception should not - // make the graph disappear with a load failure emit, as the cached data - // is already displayed. The periodic updates will still try to fetch the - // data and update the graph. + .catchError((Object error, StackTrace stackTrace) { + const errorMessage = 'Failed to load CACHED portfolio profit/loss'; + _log.warning(errorMessage, error, stackTrace); + // ignore cached errors, as the periodic refresh attempts should recover + // at the cost of a longer first loading time. }); + + // Fetch the un-cached version of the chart to update the cache. + await _sdk.waitForEnabledCoinsToPassThreshold(supportedCoins); + final activeCoins = await _removeInactiveCoins(supportedCoins); + if (activeCoins.isNotEmpty) { + await _getProfitLossChart(event, activeCoins, useCache: false) + .then(emit.call) + .catchError((Object e, StackTrace s) { + _log.severe('Failed to load uncached profit/loss chart', e, s); + // Ignore un-cached errors, as a transaction loading exception should not + // make the graph disappear with a load failure emit, as the cached data + // is already displayed. The periodic updates will still try to fetch the + // data and update the graph. + }); + } + } catch (error, stackTrace) { + _log.shout('Failed to load portfolio profit/loss', 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. } await emit.forEach( - Stream.periodic(event.updateFrequency).asyncMap((_) async { - return _getSortedProfitLossChartForCoins( - event, - useCache: false, - ); - }), - onData: (profitLossChart) { - if (profitLossChart.isEmpty) { - return state; - } - - final unCachedProfitIncrease = profitLossChart.increase; - final unCachedPercentageIncrease = profitLossChart.percentageIncrease; - return PortfolioProfitLossChartLoadSuccess( - profitLossChart: profitLossChart, - totalValue: unCachedProfitIncrease, - percentageIncrease: unCachedPercentageIncrease, - coins: event.coins, - fiatCurrency: event.fiatCoinId, - selectedPeriod: event.selectedPeriod, - walletId: event.walletId, - ); - }, + Stream.periodic(event.updateFrequency).asyncMap( + (_) async => _getProfitLossChart(event, event.coins, useCache: false), + ), + onData: (ProfitLossState updatedChartState) => updatedChartState, onError: (e, s) { - logger - .log( - 'Failed to load portfolio profit/loss: $e', - isError: true, - trace: s, - ) - .ignore(); + _log.shout('Failed to load portfolio profit/loss', e, s); return ProfitLossLoadFailure( - error: TextError(error: 'Failed to load portfolio profit/loss: $e'), + error: TextError(error: 'Failed to load portfolio profit/loss'), selectedPeriod: event.selectedPeriod, ); }, @@ -127,33 +108,39 @@ class ProfitLossBloc extends Bloc { List coins, { required bool useCache, }) async { - final filteredChart = await _getSortedProfitLossChartForCoins( - event, - useCache: useCache, - ); - final unCachedProfitIncrease = filteredChart.increase; - final unCachedPercentageIncrease = filteredChart.percentageIncrease; - return PortfolioProfitLossChartLoadSuccess( - profitLossChart: filteredChart, - totalValue: unCachedProfitIncrease, - percentageIncrease: unCachedPercentageIncrease, - coins: coins, - fiatCurrency: event.fiatCoinId, - selectedPeriod: event.selectedPeriod, - walletId: event.walletId, - ); + // Do not let exceptions stop the periodic updates. Let the periodic stream + // retry on the next failure instead of exiting. + try { + final filteredChart = await _getSortedProfitLossChartForCoins( + event, + useCache: useCache, + ); + final unCachedProfitIncrease = filteredChart.increase; + final unCachedPercentageIncrease = filteredChart.percentageIncrease; + return PortfolioProfitLossChartLoadSuccess( + profitLossChart: filteredChart, + totalValue: unCachedProfitIncrease, + percentageIncrease: unCachedPercentageIncrease, + coins: coins, + fiatCurrency: event.fiatCoinId, + selectedPeriod: event.selectedPeriod, + walletId: event.walletId, + ); + } catch (error, stackTrace) { + _log.shout('Failed periodic profit/loss chart update', error, stackTrace); + return state; + } } Future> _removeUnsupportedCons( - ProfitLossPortfolioChartLoadRequested event, { - bool allowInactiveCoins = true, - }) async { - final List coins = List.from(event.coins); - for (final coin in event.coins) { + List walletCoins, + String fiatCoinId, + ) async { + final coins = List.of(walletCoins); + for (final coin in coins) { final isCoinSupported = await _profitLossRepository.isCoinChartSupported( coin.id, - event.fiatCoinId, - allowInactiveCoins: allowInactiveCoins, + fiatCoinId, ); if (coin.isTestCoin || !isCoinSupported) { coins.remove(coin); @@ -168,26 +155,18 @@ class ProfitLossBloc extends Bloc { ) async { final eventState = state; if (eventState is! PortfolioProfitLossChartLoadSuccess) { - emit( + return emit( PortfolioProfitLossChartLoadInProgress( selectedPeriod: event.selectedPeriod, ), ); } - - assert( - eventState is PortfolioProfitLossChartLoadSuccess, - 'Selected period can only be changed when ' - 'the state is PortfolioProfitLossChartLoadSuccess', - ); - - final successState = eventState as PortfolioProfitLossChartLoadSuccess; add( ProfitLossPortfolioChartLoadRequested( - coins: successState.coins, - fiatCoinId: successState.fiatCurrency, + coins: eventState.coins, + fiatCoinId: eventState.fiatCurrency, selectedPeriod: event.selectedPeriod, - walletId: successState.walletId, + walletId: eventState.walletId, ), ); } @@ -196,6 +175,11 @@ class ProfitLossBloc extends Bloc { ProfitLossPortfolioChartLoadRequested event, { bool useCache = true, }) async { + if (!await _sdk.auth.isSignedIn()) { + _log.warning('Error loading profit/loss chart: User is not signed in'); + return ChartData.empty(); + } + final chartsList = await Future.wait( event.coins.map((coin) async { // Catch any errors and return an empty chart to prevent a single coin @@ -208,20 +192,19 @@ class ProfitLossBloc extends Bloc { useCache: useCache, ); - final startIndex = profitLosses.indexOf( - profitLosses.firstWhere((element) => element.profitLoss != 0), - ); - - if (startIndex == -1) { - profitLosses.removeRange(0, startIndex); + final firstNonZeroProfitLossIndex = + profitLosses.indexWhere((element) => element.profitLoss != 0); + if (firstNonZeroProfitLossIndex == -1) { + _log.info('No non-zero profit/loss data found for ${coin.abbr}'); + return ChartData.empty(); } - return profitLosses.toChartData(); - } catch (e) { - logger.log( - 'Failed to load cached profit/loss for coin ${coin.abbr}: $e', - isError: true, - ); + final nonZeroProfitLosses = + profitLosses.sublist(firstNonZeroProfitLossIndex); + return nonZeroProfitLosses.toChartData(); + } catch (e, s) { + final cached = useCache ? 'cached' : 'uncached'; + _log.severe('Failed to load $cached profit/loss: ${coin.abbr}', e, s); return ChartData.empty(); } }), @@ -230,4 +213,16 @@ class ProfitLossBloc extends Bloc { chartsList.removeWhere((element) => element.isEmpty); return Charts.merge(chartsList)..sort((a, b) => a.x.compareTo(b.x)); } + + Future> _removeInactiveCoins(List coins) async { + final coinsCopy = List.of(coins); + final activeCoins = await _sdk.assets.getActivatedAssets(); + final activeCoinsMap = activeCoins.map((e) => e.id).toSet(); + for (final coin in coins) { + if (!activeCoinsMap.contains(coin.id)) { + coinsCopy.remove(coin); + } + } + return coinsCopy; + } } diff --git a/lib/bloc/cex_market_data/profit_loss/profit_loss_calculator.dart b/lib/bloc/cex_market_data/profit_loss/profit_loss_calculator.dart index 231b38b49a..d95938257d 100644 --- a/lib/bloc/cex_market_data/profit_loss/profit_loss_calculator.dart +++ b/lib/bloc/cex_market_data/profit_loss/profit_loss_calculator.dart @@ -1,8 +1,8 @@ import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:web_dex/bloc/cex_market_data/profit_loss/extensions/profit_loss_transaction_extension.dart'; import 'package:web_dex/bloc/cex_market_data/profit_loss/models/price_stamped_transaction.dart'; import 'package:web_dex/bloc/cex_market_data/profit_loss/models/profit_loss.dart'; -import 'package:komodo_defi_types/komodo_defi_types.dart'; class ProfitLossCalculator { ProfitLossCalculator(this._cexRepository); diff --git a/lib/bloc/cex_market_data/profit_loss/profit_loss_repository.dart b/lib/bloc/cex_market_data/profit_loss/profit_loss_repository.dart index a574583f63..ec47cdb7c0 100644 --- a/lib/bloc/cex_market_data/profit_loss/profit_loss_repository.dart +++ b/lib/bloc/cex_market_data/profit_loss/profit_loss_repository.dart @@ -4,8 +4,10 @@ import 'dart:math'; import 'package:hive/hive.dart'; import 'package:komodo_cex_market_data/komodo_cex_market_data.dart' as cex; import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; +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:web_dex/bloc/cex_market_data/charts.dart'; import 'package:web_dex/bloc/cex_market_data/mockup/performance_mode.dart'; import 'package:web_dex/bloc/cex_market_data/profit_loss/demo_profit_loss_repository.dart'; @@ -13,10 +15,7 @@ import 'package:web_dex/bloc/cex_market_data/profit_loss/models/adapters/adapter import 'package:web_dex/bloc/cex_market_data/profit_loss/models/profit_loss.dart'; import 'package:web_dex/bloc/cex_market_data/profit_loss/models/profit_loss_cache.dart'; import 'package:web_dex/bloc/cex_market_data/profit_loss/profit_loss_calculator.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/mm2/mm2_api/mm2_api.dart'; -import 'package:web_dex/shared/utils/utils.dart'; class ProfitLossRepository { ProfitLossRepository({ @@ -25,47 +24,28 @@ class ProfitLossRepository { required cex.CexRepository cexRepository, required TransactionHistoryRepo transactionHistoryRepo, required ProfitLossCalculator profitLossCalculator, - required CoinsRepo coinsRepository, + required KomodoDefiSdk sdk, }) : _transactionHistoryRepo = transactionHistoryRepo, _cexRepository = cexRepository, _profitLossCacheProvider = profitLossCacheProvider, _profitLossCalculator = profitLossCalculator, - _coinsRepository = coinsRepository; - - final PersistenceProvider _profitLossCacheProvider; - final cex.CexRepository _cexRepository; - final TransactionHistoryRepo _transactionHistoryRepo; - final ProfitLossCalculator _profitLossCalculator; - final CoinsRepo _coinsRepository; - - static Future ensureInitialized() async { - Hive - ..registerAdapter(FiatValueAdapter()) - ..registerAdapter(ProfitLossAdapter()) - ..registerAdapter(ProfitLossCacheAdapter()); - } - - Future clearCache() async { - await _profitLossCacheProvider.deleteAll(); - } + _sdk = sdk; /// Return a new instance of [ProfitLossRepository] with default values. /// /// If [demoMode] is provided, it will return a [MockProfitLossRepository]. factory ProfitLossRepository.withDefaults({ - String cacheTableName = 'profit_loss', required TransactionHistoryRepo transactionHistoryRepo, required cex.CexRepository cexRepository, - required CoinsRepo coinsRepository, - required Mm2Api mm2Api, + required KomodoDefiSdk sdk, + String cacheTableName = 'profit_loss', PerformanceMode? demoMode, }) { if (demoMode != null) { return MockProfitLossRepository.withDefaults( performanceMode: demoMode, - coinsRepository: coinsRepository, cacheTableName: 'mock_${cacheTableName}_${demoMode.name}', - mm2Api: mm2Api, + sdk: sdk, ); } @@ -75,7 +55,34 @@ class ProfitLossRepository { HiveLazyBoxProvider(name: cacheTableName), cexRepository: cexRepository, profitLossCalculator: RealisedProfitLossCalculator(cexRepository), - coinsRepository: coinsRepository, + sdk: sdk, + ); + } + + final PersistenceProvider _profitLossCacheProvider; + final cex.CexRepository _cexRepository; + final TransactionHistoryRepo _transactionHistoryRepo; + final ProfitLossCalculator _profitLossCalculator; + final KomodoDefiSdk _sdk; + + final _log = Logger('profit-loss-repository'); + + static Future ensureInitialized() async { + Hive + ..registerAdapter(FiatValueAdapter()) + ..registerAdapter(ProfitLossAdapter()) + ..registerAdapter(ProfitLossCacheAdapter()); + } + + Future clearCache() async { + final stopwatch = Stopwatch()..start(); + _log.fine('Clearing profit/loss cache'); + + await _profitLossCacheProvider.deleteAll(); + + stopwatch.stop(); + _log.fine( + 'Profit/loss cache cleared in ${stopwatch.elapsedMilliseconds}ms', ); } @@ -93,20 +100,29 @@ class ProfitLossRepository { AssetId coinId, String fiatCoinId, { bool allowFiatAsBase = false, - bool allowInactiveCoins = false, }) async { - if (!allowInactiveCoins) { - final coin = await _coinsRepository.getEnabledCoin(coinId.id); - if (coin == null || coin.isActivating || !coin.isActive) { - return false; - } - } + final stopwatch = Stopwatch()..start(); + final coinTicker = coinId.symbol.configSymbol.toUpperCase(); + _log.fine( + 'Checking if coin $coinTicker is supported for profit/loss calculation', + ); + final supportedCoinsStopwatch = Stopwatch()..start(); final supportedCoins = await _cexRepository.getCoinList(); - final coinTicker = abbr2Ticker(coinId.id).toUpperCase(); + supportedCoinsStopwatch.stop(); + _log.fine( + 'Fetched ${supportedCoins.length} supported coins in ' + '${supportedCoinsStopwatch.elapsedMilliseconds}ms', + ); + // Allow fiat coins through, as they are represented by a constant value, // 1, in the repository layer and are not supported by the CEX API if (allowFiatAsBase && coinId.id == fiatCoinId.toUpperCase()) { + stopwatch.stop(); + _log.fine( + 'Coin $coinTicker is a fiat coin, supported: true ' + '(total: ${stopwatch.elapsedMilliseconds}ms)', + ); return true; } @@ -114,7 +130,15 @@ class ProfitLossRepository { baseCoinTicker: coinTicker, relCoinTicker: fiatCoinId.toUpperCase(), ); - return coinPair.isCoinSupported(supportedCoins); + final isSupported = coinPair.isCoinSupported(supportedCoins); + + stopwatch.stop(); + _log.fine( + 'Coin $coinTicker support check completed in ' + '${stopwatch.elapsedMilliseconds}ms, supported: $isSupported', + ); + + return isSupported; } /// Get the profit/loss data for a coin based on the transactions @@ -135,37 +159,84 @@ class ProfitLossRepository { String walletId, { bool useCache = true, }) async { + final methodStopwatch = Stopwatch()..start(); + _log.fine( + 'Getting profit/loss for ${coinId.id} in $fiatCoinId for wallet $walletId, ' + 'useCache: $useCache', + ); + + final userStopwatch = Stopwatch()..start(); + final currentUser = await _sdk.auth.currentUser; + userStopwatch.stop(); + + if (currentUser == null) { + _log.warning('No current user found when fetching profit/loss'); + methodStopwatch.stop(); + return []; + } + _log.fine( + 'Current user fetched in ${userStopwatch.elapsedMilliseconds}ms, ' + 'isHd: ${currentUser.isHd}', + ); + if (useCache) { + final cacheStopwatch = Stopwatch()..start(); final String compoundKey = ProfitLossCache.getPrimaryKey( - coinId.id, - fiatCoinId, - walletId, + coinId: coinId.id, + fiatCurrency: fiatCoinId, + walletId: walletId, + isHdWallet: currentUser.isHd, ); final ProfitLossCache? profitLossCache = await _profitLossCacheProvider.get(compoundKey); final bool cacheExists = profitLossCache != null; + cacheStopwatch.stop(); if (cacheExists) { + _log.fine( + 'ProfitLossCache hit for ${coinId.id} in $fiatCoinId for wallet $walletId ' + 'in ${cacheStopwatch.elapsedMilliseconds}ms, ' + 'entries: ${profitLossCache.profitLosses.length}', + ); + methodStopwatch.stop(); return profitLossCache.profitLosses; } + _log.fine( + 'ProfitLossCache miss for ${coinId.id} in $fiatCoinId for wallet $walletId ' + 'in ${cacheStopwatch.elapsedMilliseconds}ms', + ); } + final supportCheckStopwatch = Stopwatch()..start(); final isCoinSupported = await isCoinChartSupported( coinId, fiatCoinId, ); + supportCheckStopwatch.stop(); + if (!isCoinSupported) { + _log.fine( + 'Coin ${coinId.id} is not supported for profit/loss calculation ' + '(checked in ${supportCheckStopwatch.elapsedMilliseconds}ms)', + ); + methodStopwatch.stop(); return []; } + final txStopwatch = Stopwatch()..start(); + _log.fine('Fetching transactions for ${coinId.id}'); final transactions = - await _transactionHistoryRepo.fetchCompletedTransactions( - // TODO: Refactor referenced coinsBloc method to a repository. - // NB: Even though the class is called [CoinsBloc], it is not a Bloc. - coinId, + await _transactionHistoryRepo.fetchCompletedTransactions(coinId); + txStopwatch.stop(); + _log.fine( + 'Fetched ${transactions.length} transactions for ${coinId.id} ' + 'in ${txStopwatch.elapsedMilliseconds}ms', ); if (transactions.isEmpty) { + _log.fine('No transactions found for ${coinId.id}, caching empty result'); + + final cacheInsertStopwatch = Stopwatch()..start(); await _profitLossCacheProvider.insert( ProfitLossCache( coinId: coinId.id, @@ -173,18 +244,35 @@ class ProfitLossRepository { fiatCoinId: fiatCoinId, lastUpdated: DateTime.now(), walletId: walletId, + isHdWallet: currentUser.isHd, ), ); + cacheInsertStopwatch.stop(); + _log.fine( + 'Cached empty profit/loss for ${coinId.id} ' + 'in ${cacheInsertStopwatch.elapsedMilliseconds}ms', + ); + methodStopwatch.stop(); return []; } + final calcStopwatch = Stopwatch()..start(); + _log.fine( + 'Calculating profit/loss for ${coinId.id} with ${transactions.length} transactions', + ); final List profitLosses = await _profitLossCalculator.getProfitFromTransactions( transactions, coinId: coinId.id, fiatCoinId: fiatCoinId, ); + calcStopwatch.stop(); + _log.fine( + 'Calculated ${profitLosses.length} profit/loss entries for ${coinId.id} ' + 'in ${calcStopwatch.elapsedMilliseconds}ms', + ); + final cacheInsertStopwatch = Stopwatch()..start(); await _profitLossCacheProvider.insert( ProfitLossCache( coinId: coinId.id, @@ -192,9 +280,15 @@ class ProfitLossRepository { fiatCoinId: fiatCoinId, lastUpdated: DateTime.now(), walletId: walletId, + isHdWallet: currentUser.isHd, ), ); - + cacheInsertStopwatch.stop(); + _log.fine( + 'Cached ${profitLosses.length} profit/loss entries for ${coinId.id} ' + 'in ${cacheInsertStopwatch.elapsedMilliseconds}ms', + ); + methodStopwatch.stop(); return profitLosses; } } diff --git a/lib/bloc/cex_market_data/sdk_auth_activation_extension.dart b/lib/bloc/cex_market_data/sdk_auth_activation_extension.dart new file mode 100644 index 0000000000..48c00d2319 --- /dev/null +++ b/lib/bloc/cex_market_data/sdk_auth_activation_extension.dart @@ -0,0 +1,74 @@ +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/model/coin.dart'; + +extension SdkAuthActivationExtension on KomodoDefiSdk { + /// Waits for the enabled coins to pass the provided threshold of the provided + /// wallet coins list until the provided timeout. This is used to delay the + /// portfolio growth chart loading attempt until at least x% of the expected + /// wallet coins are enabled. + /// Returns true if the enabled coins have passed the threshold, false if the + /// timeout was reached. + Future waitForEnabledCoinsToPassThreshold( + List walletCoins, { + double threshold = 0.5, + Duration timeout = const Duration(seconds: 30), + Duration delay = const Duration(milliseconds: 500), + }) async { + if (timeout <= Duration.zero) { + throw ArgumentError.value(timeout, 'timeout', 'is negative'); + } + if (delay <= Duration.zero) { + throw ArgumentError.value(delay, 'delay', 'is negative'); + } + + final log = Logger('SdkAuthActivationExtension'); + final walletCoinIds = walletCoins.map((e) => e.id).toSet(); + final stopwatch = Stopwatch()..start(); + while (true) { + final isAboveThreshold = + await _areEnabledCoinsAboveThreshold(walletCoinIds, threshold); + if (isAboveThreshold) { + log.fine( + 'Enabled coins have passed the threshold in ' + '${stopwatch.elapsedMilliseconds}ms.', + ); + stopwatch.stop(); + return true; + } + + if (stopwatch.elapsed >= timeout) { + log.warning( + 'Timeout of ${timeout.inSeconds}s reached while waiting for enabled ' + 'coins to pass the threshold.', + ); + stopwatch.stop(); + return false; + } + + await Future.delayed(delay); + } + } + + Future _areEnabledCoinsAboveThreshold( + Set walletCoins, + double threshold, + ) async { + if (walletCoins.isEmpty) { + throw ArgumentError.value(walletCoins, 'walletCoins', 'is empty'); + } + + if (threshold <= 0 || threshold > 1) { + throw ArgumentError.value(threshold, 'threshold', 'is out of range'); + } + + final enabledCoins = await assets.getActivatedAssets(); + final enabledCoinsMap = enabledCoins.map((e) => e.id).toSet(); + + final enabledWalletCoins = walletCoins.intersection(enabledCoinsMap); + final enabledWalletCoinsPercentage = + enabledWalletCoins.length / walletCoins.length; + return enabledWalletCoinsPercentage >= threshold; + } +} diff --git a/lib/bloc/coins_bloc/asset_coin_extension.dart b/lib/bloc/coins_bloc/asset_coin_extension.dart index b59376c4a1..cada9e594e 100644 --- a/lib/bloc/coins_bloc/asset_coin_extension.dart +++ b/lib/bloc/coins_bloc/asset_coin_extension.dart @@ -5,13 +5,6 @@ import 'package:web_dex/model/coin_type.dart'; extension AssetCoinExtension on Asset { Coin toCoin() { - // Create protocol data if needed - ProtocolData? protocolData; - protocolData = ProtocolData( - platform: id.parentId?.id ?? '', - contractAddress: '', - ); - final CoinType type = protocol.subClass.toCoinType(); // temporary measure to get metadata, like `wallet_only`, that isn't exposed // by the SDK (and might be phased out completely later on) @@ -25,6 +18,11 @@ extension AssetCoinExtension on Asset { // This is the logic from the previous _getCoinMode function final isSegwit = id.id.toLowerCase().contains('-segwit'); + final ProtocolData protocolData = ProtocolData( + platform: id.parentId?.id ?? platform ?? '' , + contractAddress: contractAddress ?? '', + ); + return Coin( type: type, abbr: id.id, @@ -53,6 +51,8 @@ extension AssetCoinExtension on Asset { String? get contractAddress => protocol.config .valueOrNull('protocol', 'protocol_data', 'contract_address'); + String? get platform => protocol.config + .valueOrNull('protocol', 'protocol_data', 'platform'); } extension CoinTypeExtension on CoinSubClass { diff --git a/lib/bloc/coins_bloc/coins_bloc.dart b/lib/bloc/coins_bloc/coins_bloc.dart index 2560aaf702..99035ae056 100644 --- a/lib/bloc/coins_bloc/coins_bloc.dart +++ b/lib/bloc/coins_bloc/coins_bloc.dart @@ -5,6 +5,7 @@ import 'package:bloc_concurrency/bloc_concurrency.dart'; import 'package:equatable/equatable.dart'; 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/bloc/coins_bloc/coins_repo.dart'; import 'package:web_dex/blocs/trezor_coins_bloc.dart'; import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; @@ -12,7 +13,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/wallet.dart'; -import 'package:web_dex/shared/utils/utils.dart'; part 'coins_event.dart'; part 'coins_state.dart'; @@ -26,7 +26,7 @@ class CoinsBloc extends Bloc { this._mm2Api, ) : super(CoinsState.initial()) { on(_onCoinsStarted, transformer: droppable()); - // TODO: move auth listener to ui layer: bloclistener fires auth events + // TODO: move auth listener to ui layer: bloclistener should fire auth events on(_onCoinsBalanceMonitoringStarted); on(_onCoinsBalanceMonitoringStopped); on(_onCoinsRefreshed, transformer: droppable()); @@ -53,6 +53,8 @@ class CoinsBloc extends Bloc { // handled, which are currently done through the trezor "bloc" final TrezorCoinsBloc _trezorBloc; + final _log = Logger('CoinsBloc'); + StreamSubscription? _enabledCoinsSubscription; Timer? _updateBalancesTimer; Timer? _updatePricesTimer; @@ -76,12 +78,14 @@ class CoinsBloc extends Bloc { Emitter emit, ) async { try { - // Get current coin - final coin = state.coins[event.coinId]; + // 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 + final coin = state.walletCoins[event.coinId]; if (coin == null) return; // Get pubkeys from the SDK through the repo - final asset = _kdfSdk.assets.assetsFromTicker(event.coinId).single; + final asset = _kdfSdk.assets.available[coin.id]!; final pubkeys = await _kdfSdk.pubkeys.getPubkeys(asset); // Update state with new pubkeys @@ -94,12 +98,7 @@ class CoinsBloc extends Bloc { ), ); } catch (e, s) { - log( - 'Failed to get pubkeys for ${event.coinId}: $e', - isError: true, - path: 'coins_bloc => _onCoinsPubkeysRequested', - trace: s, - ).ignore(); + _log.shout('Failed to get pubkeys for ${event.coinId}', e, s); } } @@ -143,8 +142,8 @@ class CoinsBloc extends Bloc { await emit.forEach( coinUpdateStream, onData: (Coin coin) => state.copyWith( - walletCoins: {...state.walletCoins, coin.abbr: coin}, - coins: {...state.coins, coin.abbr: coin}, + walletCoins: {...state.walletCoins, coin.id.id: coin}, + coins: {...state.coins, coin.id.id: coin}, ), ); } @@ -158,22 +157,22 @@ class CoinsBloc extends Bloc { final walletCoins = Map.of(state.walletCoins); if (coin.isActivating || coin.isActive || coin.isSuspended) { - await _kdfSdk.addActivatedCoins([coin.abbr]); + await _kdfSdk.addActivatedCoins([coin.id.id]); emit( state.copyWith( - walletCoins: {...walletCoins, coin.abbr: coin}, - coins: {...state.coins, coin.abbr: coin}, + walletCoins: {...walletCoins, coin.id.id: coin}, + coins: {...state.coins, coin.id.id: coin}, ), ); } if (coin.isInactive) { - walletCoins.remove(coin.abbr); - await _kdfSdk.removeActivatedCoins([coin.abbr]); + walletCoins.remove(coin.id.id); + await _kdfSdk.removeActivatedCoins([coin.id.id]); emit( state.copyWith( walletCoins: walletCoins, - coins: {...state.coins, coin.abbr: coin}, + coins: {...state.coins, coin.id.id: coin}, ), ); } @@ -241,9 +240,11 @@ class CoinsBloc extends Bloc { await emit.forEach( coinUpdates, onData: (coin) => state - .copyWith(walletCoins: {...state.walletCoins, coin.abbr: coin}), + .copyWith(walletCoins: {...state.walletCoins, coin.id.id: coin}), ); } + + add(CoinsBalancesRefreshed()); } Future _onCoinsDeactivated( @@ -252,23 +253,23 @@ class CoinsBloc extends Bloc { ) async { for (final coinId in event.coinIds) { final coin = state.walletCoins[coinId]!; - log( - 'Disabling a ${coin.name} ($coinId)', - path: 'coins_bloc => disable', - ).ignore(); + _log.info('Disabling a ${coin.name} ($coinId)'); coin.reset(); - await _kdfSdk.removeActivatedCoins([coin.abbr]); - await _mm2Api.disableCoin(coin.abbr); + try { + await _kdfSdk.removeActivatedCoins([coin.id.id]); + await _mm2Api.disableCoin(coin.id.id); - final newWalletCoins = Map.of(state.walletCoins) - ..remove(coin.abbr); - final newCoins = Map.of(state.coins); - newCoins[coin.abbr]!.state = CoinState.inactive; - emit(state.copyWith(walletCoins: newWalletCoins, coins: newCoins)); + final newWalletCoins = Map.of(state.walletCoins) + ..remove(coin.id.id); + final newCoins = Map.of(state.coins); + newCoins[coin.id.id]!.state = CoinState.inactive; + emit(state.copyWith(walletCoins: newWalletCoins, coins: newCoins)); - log('${coin.name} has been disabled', path: 'coins_bloc => disable') - .ignore(); + _log.info('${coin.name} has been disabled'); + } catch (e, s) { + _log.severe('Failed to disable coin $coinId', e, s); + } } } @@ -280,18 +281,15 @@ class CoinsBloc extends Bloc { final prices = await _coinsRepo.fetchCurrentPrices(); if (prices == null) { - log( - 'Coin prices list empty/null', - isError: true, - path: 'coins_bloc => _onPricesUpdated', - ).ignore(); + _log.severe('Coin prices list empty/null'); return; } final coins = Map.of(state.coins); for (final entry in state.coins.entries) { final coin = entry.value; - final CexPrice? usdPrice = prices[abbr2Ticker(coin.abbr)]; + final CexPrice? usdPrice = + prices[coin.id.symbol.configSymbol.toUpperCase()]; if (usdPrice != coin.usdPrice) { changed = true; @@ -315,21 +313,24 @@ class CoinsBloc extends Bloc { ); } - log('CEX prices updated', path: 'coins_bloc => updateCoinsCexPrices') - .ignore(); + _log.info('Coin CEX prices updated'); } Future _onLogin( CoinsSessionStarted event, Emitter emit, ) async { - _coinsRepo.flushCache(); - _currentUserCache = event.signedInUser; - await _activateLoginWalletCoins(emit); - emit(state.copyWith(loginActivationFinished: true)); + try { + _coinsRepo.flushCache(); + _currentUserCache = event.signedInUser; + await _activateLoginWalletCoins(emit); + emit(state.copyWith(loginActivationFinished: true)); - add(CoinsBalancesRefreshed()); - add(CoinsBalanceMonitoringStarted()); + add(CoinsBalancesRefreshed()); + add(CoinsBalanceMonitoringStarted()); + } catch (e, s) { + _log.shout('Error on login', e, s); + } } Future _onLogout( @@ -346,10 +347,9 @@ class CoinsBloc extends Bloc { case WalletType.hdwallet: coin.reset(); final newWalletCoins = Map.of(state.walletCoins); - newWalletCoins.remove(coin.abbr.toUpperCase()); + newWalletCoins.remove(coin.id.id.toUpperCase()); emit(state.copyWith(walletCoins: newWalletCoins)); - log('${coin.name} has been removed', path: 'coins_bloc => _onLogout') - .ignore(); + _log.info('Logout: ${coin.name} has been removed from wallet coins'); case WalletType.trezor: case WalletType.metamask: case WalletType.keplr: @@ -376,21 +376,15 @@ class CoinsBloc extends Bloc { Iterable coins, Emitter emit, ) async { - // Start off by emitting the newly activated coins so that they all appear - // in the list at once, rather than one at a time as they are activated - _prePopulateListWithActivatingCoins(coins, emit); - try { + // Start off by emitting the newly activated coins so that they all appear + // in the list at once, rather than one at a time as they are activated + emit(_prePopulateListWithActivatingCoins(coins)); + await _kdfSdk.addActivatedCoins(coins); } catch (e, s) { - log( - 'Failed to activate coins in SDK: $e', - isError: true, - path: 'coins_bloc => _activateCoins', - trace: s, - ).ignore(); - // Update state to reflect failure - return []; + _log.shout('Failed to add activated coins to SDK metadata field', e, s); + rethrow; } final enabledAssets = await _kdfSdk.assets.getEnabledCoins(); @@ -405,8 +399,8 @@ class CoinsBloc extends Bloc { results.add(coin); emit( state.copyWith( - walletCoins: {...state.walletCoins, coin.abbr: coin}, - coins: {...state.coins, coin.abbr: coin}, + walletCoins: {...state.walletCoins, coin.id.id: coin}, + coins: {...state.coins, coin.id.id: coin}, ), ); } @@ -414,10 +408,7 @@ class CoinsBloc extends Bloc { return results; } - void _prePopulateListWithActivatingCoins( - Iterable coins, - Emitter emit, - ) { + CoinsState _prePopulateListWithActivatingCoins(Iterable coins) { final activatingCoins = Map.fromIterable( coins .map( @@ -431,13 +422,11 @@ class CoinsBloc extends Bloc { ) .where((coin) => coin != null) .cast(), - key: (element) => (element as Coin).abbr, + key: (element) => (element as Coin).id.id, ); - emit( - state.copyWith( - walletCoins: {...state.walletCoins, ...activatingCoins}, - coins: {...state.coins, ...activatingCoins}, - ), + return state.copyWith( + walletCoins: {...state.walletCoins, ...activatingCoins}, + coins: {...state.coins, ...activatingCoins}, ); } @@ -465,11 +454,7 @@ class CoinsBloc extends Bloc { break; } } catch (e, s) { - log( - 'Error activating coin ${coin!.id.toString()}', - isError: true, - trace: s, - ); + _log.shout('Error activating coin ${coin!.id}', e, s); } return coin; @@ -478,7 +463,7 @@ class CoinsBloc extends Bloc { Future _activateTrezorCoin(Coin coin, String coinId) async { final asset = _kdfSdk.assets.available[coin.id]; if (asset == null) { - log('Failed to find asset for coin: ${coin.id}', isError: true); + _log.severe('Failed to find asset for coin: ${coin.id}'); return coin.copyWith(state: CoinState.suspended); } final accounts = await _trezorBloc.activateCoin(asset); @@ -488,18 +473,13 @@ class CoinsBloc extends Bloc { Future _activateIguanaCoin(Coin coin) async { try { - log('Enabling a ${coin.name}', path: 'coins_bloc => enable').ignore(); + _log.info('Enabling iguana coin: ${coin.id.id}'); await _coinsRepo.activateCoinsSync([coin]); coin.state = CoinState.active; - log('${coin.name} has enabled', path: 'coins_bloc => enable').ignore(); + _log.info('Iguana coin ${coin.name} has been enabled'); } catch (e, s) { coin.state = CoinState.suspended; - log( - 'Failed to activate iguana coin: $e', - isError: true, - path: 'coins_bloc => _activateIguanaCoin', - trace: s, - ).ignore(); + _log.shout('Failed to activate iguana coin', e, s); } return coin; } @@ -522,11 +502,12 @@ class CoinsBloc extends Bloc { for (int i = 0; i < attempts; i++) { final List suspended = state.walletCoins.values .where((coin) => coin.isSuspended) - .map((coin) => coin.abbr) + .map((coin) => coin.id.id) .toList(); - coinsToBeActivated.addAll(suspended); - coinsToBeActivated.addAll(_getUnactivatedWalletCoins()); + coinsToBeActivated + ..addAll(suspended) + ..addAll(_getUnactivatedWalletCoins()); if (coinsToBeActivated.isEmpty) return; yield await _activateCoins(coinsToBeActivated, emit); @@ -553,7 +534,7 @@ class CoinsBloc extends Bloc { final Coin? apiCoin = await _coinsRepo.getEnabledCoin(coinId); final coin = walletCoins[coinId]; if (coin == null) { - log('Coin $coinId removed from wallet, skipping sync').ignore(); + _log.warning('Coin $coinId removed from wallet, skipping sync'); continue; } diff --git a/lib/bloc/coins_bloc/coins_repo.dart b/lib/bloc/coins_bloc/coins_repo.dart index cb895ccbbc..584a5276c2 100644 --- a/lib/bloc/coins_bloc/coins_repo.dart +++ b/lib/bloc/coins_bloc/coins_repo.dart @@ -7,8 +7,10 @@ import 'package:http/http.dart' as http; import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart' as kdf_rpc; 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:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:logging/logging.dart'; import 'package:web_dex/bloc/coins_bloc/asset_coin_extension.dart'; import 'package:web_dex/blocs/trezor_coins_bloc.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; @@ -24,7 +26,6 @@ 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/shared/constants.dart'; -import 'package:web_dex/shared/utils/utils.dart'; class CoinsRepo { CoinsRepo({ @@ -46,6 +47,8 @@ class CoinsRepo { // handled, which are currently done through the trezor "bloc" final TrezorCoinsBloc trezor; + final _log = Logger('CoinsRepo'); + /// { acc: { abbr: address }}, used in Fiat Page final Map> _addressCache = {}; Map _pricesCache = {}; @@ -94,19 +97,16 @@ class CoinsRepo { return _assetToCoinWithoutAddress(asset); } - @Deprecated('Use KomodoDefiSdk assets or getCoinFromId instead. ' - 'This uses the deprecated assetsFromTicker method that uses a separate ' - 'cache that does not update with custom token activation.') + @Deprecated('Use KomodoDefiSdk assets or getCoinFromId instead.') Coin? getCoin(String coinId) { if (coinId.isEmpty) return null; try { final assets = _kdfSdk.assets.assetsFromTicker(coinId); if (assets.isEmpty || assets.length > 1) { - log( + _log.warning( 'Coin "$coinId" not found. ${assets.length} results returned', - isError: true, - ).ignore(); + ); return null; } return _assetToCoinWithoutAddress(assets.single); @@ -128,17 +128,24 @@ class CoinsRepo { } Future getEnabledCoin(String coinId) async { - final enabledAssets = _kdfSdk.assets.assetsFromTicker(coinId); - if (enabledAssets.length != 1) { - return null; - } final currentUser = await _kdfSdk.auth.currentUser; if (currentUser == null) { return null; } - final coin = _assetToCoinWithoutAddress(enabledAssets.single); - final coinAddress = await getFirstPubkey(coin.abbr); + final enabledAssets = await _kdfSdk.assets.getEnabledCoins(); + final enabledAsset = enabledAssets.firstWhereOrNull( + (asset) => asset == coinId, + ); + if (enabledAsset == null) { + return null; + } + + final coin = getCoin(enabledAsset); + if (coin == null) { + return null; + } + final coinAddress = await getFirstPubkey(coin.id.id); return coin.copyWith( address: coinAddress, state: CoinState.active, @@ -167,7 +174,7 @@ class CoinsRepo { final coinsMap = Map.fromEntries(entries); for (final coinId in coinsMap.keys) { final coin = coinsMap[coinId]!; - final coinAddress = await getFirstPubkey(coin.abbr); + final coinAddress = await getFirstPubkey(coin.id.id); coinsMap[coinId] = coin.copyWith( address: coinAddress, state: CoinState.active, @@ -179,16 +186,16 @@ class CoinsRepo { Coin _assetToCoinWithoutAddress(Asset asset) { final coin = asset.toCoin(); - final balance = _balancesCache[coin.abbr]?.balance; - final sendableBalance = _balancesCache[coin.abbr]?.sendableBalance; - final price = _pricesCache[coin.abbr]; + final balance = _balancesCache[coin.id.id]?.balance; + final sendableBalance = _balancesCache[coin.id.id]?.sendableBalance; + final price = _pricesCache[coin.id.id]; Coin? parentCoin; if (asset.id.isChildAsset) { final parentCoinId = asset.id.parentId!; final parentAsset = _kdfSdk.assets.available[parentCoinId]; if (parentAsset == null) { - log('Parent coin $parentCoinId not found.', isError: true).ignore(); + _log.warning('Parent coin $parentCoinId not found.'); parentCoin = null; } else { parentCoin = _assetToCoinWithoutAddress(parentAsset); @@ -215,12 +222,7 @@ class CoinsRepo { final pubkeys = await _kdfSdk.pubkeys.getPubkeys(asset); return pubkeys.balance; } catch (e, s) { - log( - 'Failed to get coin $coinId balance: $e', - isError: true, - path: 'coins_repo => tryGetBalanceInfo', - trace: s, - ).ignore(); + _log.shout('Failed to get coin $coinId balance', e, s); return kdf_rpc.BalanceInfo.zero(); } } @@ -229,7 +231,9 @@ class CoinsRepo { final isSignedIn = await _kdfSdk.auth.isSignedIn(); if (!isSignedIn) { final coinIdList = assets.map((e) => e.id.id).join(', '); - log('No wallet signed in. Skipping activation of [$coinIdList}]'); + _log.warning( + 'No wallet signed in. Skipping activation of [$coinIdList]', + ); return; } @@ -246,11 +250,7 @@ class CoinsRepo { await _broadcastAsset(coin.copyWith(state: CoinState.active)); } catch (e, s) { - log( - 'Error activating coin: ${asset.id.id} \n$e', - isError: true, - trace: s, - ).ignore(); + _log.shout('Error activating asset: ${asset.id.id}', e, s); await _broadcastAsset( asset.toCoin().copyWith(state: CoinState.suspended), ); @@ -270,8 +270,10 @@ class CoinsRepo { Future activateCoinsSync(List coins) async { final isSignedIn = await _kdfSdk.auth.isSignedIn(); if (!isSignedIn) { - final coinIdList = coins.map((e) => e.abbr).join(', '); - log('No wallet signed in. Skipping activation of [$coinIdList}]'); + final coinIdList = coins.map((e) => e.id.id).join(', '); + _log.warning( + 'No wallet signed in. Skipping activation of [$coinIdList]', + ); return; } @@ -279,10 +281,7 @@ class CoinsRepo { try { final asset = _kdfSdk.assets.available[coin.id]; if (asset == null) { - log( - 'Coin ${coin.id} not found. Skipping activation.', - isError: true, - ).ignore(); + _log.warning('Coin ${coin.id} not found. Skipping activation.'); continue; } @@ -291,17 +290,12 @@ class CoinsRepo { // ignore: deprecated_member_use final progress = await _kdfSdk.assets.activateAsset(asset).last; if (!progress.isSuccess) { - throw StateError('Failed to activate coin ${coin.abbr}'); + throw StateError('Failed to activate coin ${coin.id.id}'); } await _broadcastAsset(coin.copyWith(state: CoinState.active)); } catch (e, s) { - log( - 'Error activating coin: ${coin.abbr} \n$e', - isError: true, - trace: s, - path: 'coins_repo->activateCoinsSync', - ).ignore(); + _log.shout('Error activating coin: ${coin.id.id} \n$e', e, s); await _broadcastAsset(coin.copyWith(state: CoinState.suspended)); } finally { // Register outside of the try-catch to ensure icon is available even @@ -320,7 +314,7 @@ class CoinsRepo { if (!await _kdfSdk.auth.isSignedIn()) return; for (final coin in coins) { - await _disableCoin(coin.abbr); + await _disableCoin(coin.id.id); await _broadcastAsset(coin.copyWith(state: CoinState.inactive)); } } @@ -329,12 +323,7 @@ class CoinsRepo { try { await _mm2.call(DisableCoinReq(coin: coinId)); } catch (e, s) { - log( - 'Error disabling $coinId: $e', - path: 'api=> disableCoin => _call', - trace: s, - isError: true, - ).ignore(); + _log.shout('Error disabling $coinId', e, s); return; } } @@ -386,12 +375,7 @@ class CoinsRepo { res = await http.get(pricesUrlV3); body = res.body; } catch (e, s) { - log( - 'Error updating price from main: $e', - path: 'cex_services => _updateFromMain => http.get', - trace: s, - isError: true, - ).ignore(); + _log.shout('Error updating price from main: $e', e, s); return null; } @@ -399,12 +383,7 @@ class CoinsRepo { try { json = jsonDecode(body) as Map; } catch (e, s) { - log( - 'Error parsing of update price from main response: $e', - path: 'cex_services => _updateFromMain => jsonDecode', - trace: s, - isError: true, - ).ignore(); + _log.shout('Error parsing of update price from main response', e, s); } if (json == null) return null; @@ -446,12 +425,7 @@ class CoinsRepo { res = await http.get(fallbackUri); body = res.body; } catch (e, s) { - log( - 'Error updating price from fallback: $e', - path: 'cex_services => _updateFromFallback => http.get', - trace: s, - isError: true, - ).ignore(); + _log.shout('Error updating price from fallback', e, s); return null; } @@ -459,12 +433,7 @@ class CoinsRepo { try { json = jsonDecode(body) as Map?; } catch (e, s) { - log( - 'Error parsing of update price from fallback response: $e', - path: 'cex_services => _updateFromFallback => jsonDecode', - trace: s, - isError: true, - ).ignore(); + _log.shout('Error parsing of update price from fallback response', e, s); } if (json == null) return null; @@ -478,11 +447,11 @@ class CoinsRepo { // Coins with the same coingeckoId supposedly have same usd price // (e.g. KMD == KMD-BEP20) final Iterable samePriceCoins = - (getKnownCoins()).where((coin) => coin.coingeckoId == coingeckoId); + getKnownCoins().where((coin) => coin.coingeckoId == coingeckoId); for (final Coin coin in samePriceCoins) { - prices[coin.abbr] = CexPrice( - ticker: coin.abbr, + prices[coin.id.id] = CexPrice( + ticker: coin.id.id, price: double.parse(pricesData['usd'].toString()), ); } @@ -525,7 +494,7 @@ class CoinsRepo { balance: newBalance, sendableBalance: newSendableBalance, ); - _balancesCache[coins[i].abbr] = + _balancesCache[coins[i].id.id] = (balance: newBalance, sendableBalance: newSendableBalance); } } @@ -538,23 +507,18 @@ class CoinsRepo { try { response = await _mm2.call(request) as Map?; } catch (e, s) { - log( - 'Error withdrawing ${request.params.coin}: $e', - path: 'api => withdraw', - trace: s, - isError: true, - ).ignore(); + _log.shout('Error withdrawing ${request.params.coin}', e, s); } if (response == null) { - log('Withdraw error: response is null', isError: true).ignore(); + _log.shout('Withdraw error: response is null'); return BlocResponse( error: TextError(error: LocaleKeys.somethingWrong.tr()), ); } if (response['error'] != null) { - log('Withdraw error: ${response['error']}', isError: true).ignore(); + _log.shout('Withdraw error: ${response['error']}'); return BlocResponse( error: withdrawErrorFactory.getError(response, request.params.coin), ); diff --git a/lib/model/kdf_auth_metadata_extension.dart b/lib/model/kdf_auth_metadata_extension.dart index 8cddf63379..7581b786be 100644 --- a/lib/model/kdf_auth_metadata_extension.dart +++ b/lib/model/kdf_auth_metadata_extension.dart @@ -1,5 +1,7 @@ import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.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'; extension KdfAuthMetadataExtension on KomodoDefiSdk { @@ -13,6 +15,21 @@ extension KdfAuthMetadataExtension on KomodoDefiSdk { return user?.wallet; } + Future> getWalletCoinIds() async { + final user = await auth.currentUser; + return user?.metadata.valueOrNull>('activated_coins') ?? []; + } + + Future> getWalletCoins() 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.findAssetsByTicker(coinId).single.toCoin()) + .toList(); + } + Future addActivatedCoins(Iterable coins) async { final existingCoins = (await auth.currentUser) ?.metadata diff --git a/lib/model/wallet.dart b/lib/model/wallet.dart index a43abbc337..6518bb71b5 100644 --- a/lib/model/wallet.dart +++ b/lib/model/wallet.dart @@ -166,26 +166,12 @@ extension KdfUserWalletExtension on KdfUser { config: WalletConfig( seedPhrase: '', pubKey: walletId.pubkeyHash, - activatedCoins: _parseActivatedCoins(walletType), + activatedCoins: metadata.valueOrNull>('activated_coins') ?? [], hasBackup: metadata['has_backup'] as bool? ?? false, type: walletType, ), ); } - - List _parseActivatedCoins(WalletType walletType) { - final activatedCoins = - metadata.valueOrNull>('activated_coins'); - if (activatedCoins == null || activatedCoins.isEmpty) { - if (walletType == WalletType.trezor) { - return enabledByDefaultTrezorCoins; - } - - return enabledByDefaultCoins; - } - - return activatedCoins; - } } extension KdfSdkWalletExtension on KomodoDefiSdk { diff --git a/lib/services/logger/get_logger.dart b/lib/services/logger/get_logger.dart index f799256cba..1ea1a3a3fc 100644 --- a/lib/services/logger/get_logger.dart +++ b/lib/services/logger/get_logger.dart @@ -1,14 +1,18 @@ +import 'dart:developer' as developer show log; import 'dart:io'; import 'package:dragon_logs/dragon_logs.dart'; import 'package:flutter/foundation.dart'; +import 'package:logging/logging.dart'; import 'package:web_dex/app_config/package_information.dart'; import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; +import 'package:web_dex/performance_analytics/performance_analytics.dart'; import 'package:web_dex/services/logger/logger.dart'; import 'package:web_dex/services/logger/mock_logger.dart'; import 'package:web_dex/services/logger/universal_logger.dart'; import 'package:web_dex/services/platform_info/plaftorm_info.dart'; import 'package:web_dex/services/storage/get_storage.dart'; +import 'package:web_dex/shared/constants.dart' show isTestMode; final LoggerInterface logger = _getLogger(); LoggerInterface _getLogger() { @@ -39,4 +43,79 @@ Future initializeLogger(Mm2Api mm2Api) async { 'osLanguage': platformInfo.osLanguage, 'screenSize': platformInfo.screenSize, }); + + Logger.root.level = kReleaseMode ? Level.INFO : Level.ALL; + Logger.root.onRecord.listen(_logToUniversalLogger); +} + +/// Copied over existing code from utils.dart log function to avoid breaking +/// changes. +/// TODO: update to do othe outstanding stacktrace parsing etc. +/// (presumably for security reasons) +Future _logToUniversalLogger(LogRecord record) async { + final timer = Stopwatch()..start(); + // todo(yurii & ivan): to finish stacktrace parsing + // if (trace != null) { + // final String errorTrace = getInfoFromStackTrace(trace); + // logger.write('$errorTrace: $errorOrUsefulData'); + // } + const isTestEnv = isTestMode || kDebugMode; + if (isTestEnv && record.error != null) { + _logDeveloperLog(record); + } + + try { + // Temporarily add log level to the message, seeing as the universal logger + // does not support log levels yet. + final message = '${record.level.name}: ${record.message} - ${record.error}'; + await logger.write(message, record.loggerName); + + // Web previews are built in profile mode, so print the stack trace for + // debugging purposes in case errors are found in PR testing. + if (kProfileMode && record.stackTrace != null) { + await logger.write('\nStacktrace: ${record.stackTrace}\n'); + } + + performance.logTimeWritingLogs(timer.elapsedMilliseconds); + } catch (e) { + // TODO: replace below with crashlytics reporting or show UI the printed + // message in a snackbar/banner. + // ignore: avoid_print + print( + 'ERROR: Writing logs failed. Exported log files may be incomplete.' + '\nError message: $e', + ); + } finally { + timer.stop(); + } +} + +void _logDeveloperLog(LogRecord record) { + final message = + '${record.level.name} [${record.loggerName}]: ${record.time}: ' + '${record.message}'; + + switch (record.level) { + case Level.SEVERE: + case Level.SHOUT: + developer.log( + message, + name: record.loggerName, + level: 1200, // Error level + error: record.error, + stackTrace: record.stackTrace, + ); + case Level.WARNING: + developer.log( + message, + name: record.loggerName, + level: 900, // Warning level + ); + default: + developer.log( + message, + name: record.loggerName, + level: 500, // Info level + ); + } } diff --git a/lib/shared/utils/utils.dart b/lib/shared/utils/utils.dart index 92ef91605b..6950d4b244 100644 --- a/lib/shared/utils/utils.dart +++ b/lib/shared/utils/utils.dart @@ -229,6 +229,7 @@ Future launchURLString( } } +// TODO: deprecate Future log( String message, { String? path, diff --git a/lib/views/wallet/coin_details/coin_details_info/coin_details_info.dart b/lib/views/wallet/coin_details/coin_details_info/coin_details_info.dart index dd3e7e41f7..60d95c6094 100644 --- a/lib/views/wallet/coin_details/coin_details_info/coin_details_info.dart +++ b/lib/views/wallet/coin_details/coin_details_info/coin_details_info.dart @@ -60,27 +60,24 @@ class _CoinDetailsInfoState extends State void initState() { super.initState(); const selectedDurationInitial = Duration(hours: 1); - final growthBloc = context.read(); - - growthBloc.add( - PortfolioGrowthLoadRequested( - coins: [widget.coin], - fiatCoinId: 'USDT', - selectedPeriod: selectedDurationInitial, - walletId: _walletId!, - ), - ); - - final ProfitLossBloc profitLossBloc = context.read(); - profitLossBloc.add( - ProfitLossPortfolioChartLoadRequested( - coins: [widget.coin], - selectedPeriod: const Duration(hours: 1), - fiatCoinId: 'USDT', - walletId: _walletId!, - ), - ); + context.read().add( + PortfolioGrowthLoadRequested( + coins: [widget.coin], + fiatCoinId: 'USDT', + selectedPeriod: selectedDurationInitial, + walletId: _walletId!, + ), + ); + + context.read().add( + ProfitLossPortfolioChartLoadRequested( + coins: [widget.coin], + selectedPeriod: const Duration(hours: 1), + fiatCoinId: 'USDT', + walletId: _walletId!, + ), + ); } @override @@ -363,11 +360,70 @@ class _CoinDetailsInfoHeader extends StatelessWidget { } } -class _CoinDetailsMarketMetricsTabBar extends StatelessWidget { +class _CoinDetailsMarketMetricsTabBar extends StatefulWidget { const _CoinDetailsMarketMetricsTabBar({required this.coin}); final Coin coin; + @override + _CoinDetailsMarketMetricsTabBarState createState() => + _CoinDetailsMarketMetricsTabBarState(); +} + +class _CoinDetailsMarketMetricsTabBarState + extends State<_CoinDetailsMarketMetricsTabBar> + with TickerProviderStateMixin { + TabController? _tabController; + int _currentIndex = 0; + + void _initializeTabController(int numTabs) { + _tabController = TabController( + length: numTabs, + vsync: this, + initialIndex: _currentIndex < numTabs ? _currentIndex : 0, + ); + + _tabController!.addListener(() { + if (_tabController!.indexIsChanging) { + setState(() { + _currentIndex = _tabController!.index; + }); + } + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + final portfolioGrowthState = context.watch().state; + final profitLossState = context.watch().state; + final isPortfolioGrowthSupported = + portfolioGrowthState is! PortfolioGrowthChartUnsupported; + final isProfitLossSupported = + profitLossState is! PortfolioProfitLossChartUnsupported; + final areChartsSupported = + isPortfolioGrowthSupported || isProfitLossSupported; + final numChartsSupported = + (isPortfolioGrowthSupported ? 1 : 0) + (isProfitLossSupported ? 1 : 0); + + if (areChartsSupported) { + if (_tabController == null || + _tabController!.length != numChartsSupported) { + _initializeTabController(numChartsSupported); + } + } else { + _tabController?.dispose(); + _tabController = null; + } + } + + @override + void dispose() { + _tabController?.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final portfolioGrowthState = context.watch().state; @@ -378,55 +434,46 @@ class _CoinDetailsMarketMetricsTabBar extends StatelessWidget { profitLossState is! PortfolioProfitLossChartUnsupported; final areChartsSupported = isPortfolioGrowthSupported || isProfitLossSupported; - final numChartsSupported = 0 + - (isPortfolioGrowthSupported ? 1 : 0) + - (isProfitLossSupported ? 1 : 0); + final numChartsSupported = + (isPortfolioGrowthSupported ? 1 : 0) + (isProfitLossSupported ? 1 : 0); if (!areChartsSupported) { return const SizedBox.shrink(); } - final TabController tabController = TabController( - length: numChartsSupported, - vsync: Navigator.of(context), - ); + if (_tabController == null) { + _initializeTabController(numChartsSupported); + } return Column( children: [ Card( child: TabBar( - controller: tabController, + controller: _tabController, tabs: [ - // spread operator used to ensure that tabs and views are - // in sync - ...([ - if (isPortfolioGrowthSupported) - Tab(text: LocaleKeys.growth.tr()), - if (isProfitLossSupported) - Tab(text: LocaleKeys.profitAndLoss.tr()), - ]), + if (isPortfolioGrowthSupported) Tab(text: LocaleKeys.growth.tr()), + if (isProfitLossSupported) + Tab(text: LocaleKeys.profitAndLoss.tr()), ], ), ), SizedBox( height: 340, child: TabBarView( - controller: tabController, + controller: _tabController, children: [ - ...([ - if (isPortfolioGrowthSupported) - SizedBox( - width: double.infinity, - height: 340, - child: PortfolioGrowthChart(initialCoins: [coin]), - ), - if (isProfitLossSupported) - SizedBox( - width: double.infinity, - height: 340, - child: PortfolioProfitLossChart(initialCoins: [coin]), - ), - ]), + if (isPortfolioGrowthSupported) + SizedBox( + width: double.infinity, + height: 340, + child: PortfolioGrowthChart(initialCoins: [widget.coin]), + ), + if (isProfitLossSupported) + SizedBox( + width: double.infinity, + height: 340, + child: PortfolioProfitLossChart(initialCoins: [widget.coin]), + ), ], ), ), diff --git a/lib/views/wallet/wallet_page/wallet_main/wallet_main.dart b/lib/views/wallet/wallet_page/wallet_main/wallet_main.dart index c2f2646293..7a50bf97c3 100644 --- a/lib/views/wallet/wallet_page/wallet_main/wallet_main.dart +++ b/lib/views/wallet/wallet_page/wallet_main/wallet_main.dart @@ -4,6 +4,7 @@ import 'package:app_theme/app_theme.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/app_config/app_config.dart'; import 'package:web_dex/bloc/assets_overview/bloc/asset_overview_bloc.dart'; @@ -20,6 +21,7 @@ import 'package:web_dex/dispatchers/popup_dispatcher.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/model/authorize_mode.dart'; import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/kdf_auth_metadata_extension.dart'; import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/router/state/routing_state.dart'; import 'package:web_dex/router/state/wallet_state.dart'; @@ -152,14 +154,21 @@ class _WalletMainState extends State final portfolioGrowthBloc = context.read(); final profitLossBloc = context.read(); final assetOverviewBloc = context.read(); - final walletCoins = - context.read().state.walletCoins.values.toList(); + final sdk = RepositoryProvider.of(context); + + // Use the historical (previously activated) wallet coins here, as the + // [CoinsBloc] state might not be updated yet if the user signs in on this + // page. Having this function refresh on [CoinsBloc] state changes is not + // ideal, as it would spam API requests each time a coin is activated, or + // balance updated. + // TODO: update to event-based approach based on soon-to-be-implemented + // balance events from the SDK + final walletCoins = await sdk.getWalletCoins(); portfolioGrowthBloc.add( PortfolioGrowthLoadRequested( coins: walletCoins, fiatCoinId: 'USDT', - updateFrequency: const Duration(minutes: 1), selectedPeriod: portfolioGrowthBloc.state.selectedPeriod, walletId: walletId, ), @@ -263,17 +272,16 @@ class _WalletMainState extends State } class _SliverSearchBarDelegate extends SliverPersistentHeaderDelegate { - final bool withBalance; - final Function(String) onSearchChange; - final Function(bool) onWithBalanceChange; - final AuthorizeMode mode; - _SliverSearchBarDelegate({ required this.withBalance, required this.onSearchChange, required this.onWithBalanceChange, required this.mode, }); + final bool withBalance; + final Function(String) onSearchChange; + final Function(bool) onWithBalanceChange; + final AuthorizeMode mode; @override final double minExtent = 110; diff --git a/pubspec.lock b/pubspec.lock index 3a3812865a..4d4aa8c66f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -795,7 +795,7 @@ packages: source: hosted version: "1.0.11" logging: - dependency: transitive + dependency: "direct main" description: name: logging sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 diff --git a/pubspec.yaml b/pubspec.yaml index 640ede1b6a..40a43e2b1f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -61,6 +61,7 @@ dependencies: crypto: 3.0.6 # dart.dev cross_file: 0.3.4+2 # flutter.dev video_player: 2.9.3 # flutter.dev + logging: 1.3.0 ## ---- google.com