diff --git a/lib/bloc/coins_bloc/coins_repo.dart b/lib/bloc/coins_bloc/coins_repo.dart index c77d05f234..28dbd6a6ff 100644 --- a/lib/bloc/coins_bloc/coins_repo.dart +++ b/lib/bloc/coins_bloc/coins_repo.dart @@ -289,7 +289,7 @@ class CoinsRepo { Exception? lastActivationException; for (final asset in assets) { - final coin = asset.toCoin(); + final coin = _assetToCoinWithoutAddress(asset); try { if (notify) _broadcastAsset(coin.copyWith(state: CoinState.activating)); @@ -416,7 +416,10 @@ class CoinsRepo { }) async { final assets = coins .map((coin) => _kdfSdk.assets.available[coin.id]) - .whereType() + // use cast instead of `whereType` to ensure an exception is thrown + // if the provided asset is not found in the SDK. An explicit + // argument error might be more apt here. + .cast() .toList(); return activateAssetsSync( @@ -432,6 +435,10 @@ class CoinsRepo { /// If [notify] is true, it will broadcast the deactivation to listeners. /// This method is used to deactivate coins that are no longer needed or /// supported by the user. + /// + /// NOTE: Only balance watchers are cancelled, the coins are not deactivated + /// in the SDK or MM2. This is a temporary solution to avoid "NoSuchCoin" + /// errors when trying to re-enable the coin later in the same session. Future deactivateCoinsSync( List coins, { bool notify = true, @@ -468,20 +475,23 @@ class CoinsRepo { _balanceWatchers.remove(child.id); }); - final deactivationTasks = [ - ...coins.map((coin) async { - await _disableCoin(coin.id.id); - if (notify) _broadcastAsset(coin.copyWith(state: CoinState.inactive)); - }), - ...allChildCoins.map((child) async { - await _disableCoin(child.id.id); - if (notify) { - _broadcastAsset(child.copyWith(state: CoinState.inactive)); - } - }), - ]; + // Skip the deactivation step for now, as it results in "NoSuchCoin" errors + // when trying to re-enable the coin later in the same session. + // TODO: Revisit this and create an issue on KDF to track the problem. + // final deactivationTasks = [ + // ...coins.map((coin) async { + // await _disableCoin(coin.id.id); + // if (notify) _broadcastAsset(coin.copyWith(state: CoinState.inactive)); + // }), + // ...allChildCoins.map((child) async { + // await _disableCoin(child.id.id); + // if (notify) { + // _broadcastAsset(child.copyWith(state: CoinState.inactive)); + // } + // }), + // ]; + // await Future.wait(deactivationTasks); - await Future.wait(deactivationTasks); await Future.wait([...parentCancelFutures, ...childCancelFutures]); } diff --git a/lib/bloc/nfts/nft_main_bloc.dart b/lib/bloc/nfts/nft_main_bloc.dart index 556fcc4c6d..604fbb508f 100644 --- a/lib/bloc/nfts/nft_main_bloc.dart +++ b/lib/bloc/nfts/nft_main_bloc.dart @@ -1,8 +1,11 @@ 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:komodo_defi_types/komodo_defi_type_utils.dart' + show retry, ExponentialBackoff; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:logging/logging.dart'; import 'package:web_dex/bloc/nfts/nft_main_repo.dart'; @@ -20,7 +23,8 @@ class NftMainBloc extends Bloc { }) : _repo = repo, _sdk = sdk, super(NftMainState.initial()) { - on(_onChainNftsUpdateRequested); + on(_onChainNftsUpdateRequested, + transformer: restartable()); on(_onTabChanged); on(_onReset); on(_onRefreshForChain); @@ -49,6 +53,8 @@ class NftMainBloc extends Bloc { ) async { emit(state.copyWith(selectedChain: () => event.chain)); if (!await _sdk.auth.isSignedIn() || !state.isInitialized) { + _log.warning( + 'User is not signed in or state is not initialized. Cannot change NFT tab.'); return; } @@ -80,11 +86,13 @@ class NftMainBloc extends Bloc { Emitter emit, ) async { if (!await _sdk.auth.isSignedIn()) { + _log.warning('User is not signed in. Cannot update NFT chains.'); return; } try { _log.info('Updating all NFT chains'); + final Map> nfts = await _getAllNfts(); final (counts, sortedChains) = _calculateNftCount(nfts); @@ -174,7 +182,16 @@ class NftMainBloc extends Bloc { Future>> _getAllNfts({ List chains = NftBlockchains.values, }) async { - await _repo.updateNft(chains); + try { + await retry( + () async => await _repo.updateNft(chains), + maxAttempts: 3, + backoffStrategy: + ExponentialBackoff(initialDelay: const Duration(seconds: 1)), + ); + } catch (e, s) { + _log.severe('Error updating NFTs for chains $chains', e, s); + } final List list = await _repo.getNfts(chains); final Map> nfts = diff --git a/lib/bloc/nfts/nft_main_repo.dart b/lib/bloc/nfts/nft_main_repo.dart index f78f00b100..b3b0b014d9 100644 --- a/lib/bloc/nfts/nft_main_repo.dart +++ b/lib/bloc/nfts/nft_main_repo.dart @@ -1,12 +1,14 @@ import 'package:easy_localization/easy_localization.dart'; +import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; +import 'package:logging/logging.dart'; import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/mm2/mm2_api/mm2_api_nft.dart'; import 'package:web_dex/mm2/mm2_api/rpc/errors.dart'; import 'package:web_dex/mm2/mm2_api/rpc/nft/get_nft_list/get_nft_list_res.dart'; +import 'package:web_dex/model/coin.dart' show Coin; import 'package:web_dex/model/nft.dart'; import 'package:web_dex/model/text_error.dart'; -import 'package:web_dex/shared/utils/utils.dart'; class NftsRepo { NftsRepo({ @@ -15,32 +17,29 @@ class NftsRepo { }) : _coinsRepo = coinsRepo, _api = api; + final Logger _log = Logger('NftsRepo'); final CoinsRepo _coinsRepo; final Mm2ApiNft _api; Future updateNft(List chains) async { - // Only runs on active nft chains + // Ensure that the parent coins for the NFT chains are activated. + await _activateParentCoins(chains); + await _api.enableNftChains(chains); final json = await _api.updateNftList(chains); if (json['error'] != null) { - log( - json['error'] as String, - path: 'nft_main_repo => updateNft', - isError: true, - ).ignore(); + _log.severe(json['error'] as String); throw ApiError(message: json['error'] as String); } } Future> getNfts(List chains) async { - // Only runs on active nft chains + // Ensure that the parent coins for the NFT chains are activated. + await _activateParentCoins(chains); + await _api.enableNftChains(chains); final json = await _api.getNftList(chains); final jsonError = json['error'] as String?; if (jsonError != null) { - log( - jsonError, - path: 'nft_main_repo => getNfts', - isError: true, - ).ignore(); + _log.severe(jsonError); if (jsonError.toLowerCase().startsWith('transport')) { throw TransportError(message: jsonError); } else { @@ -67,4 +66,29 @@ class NftsRepo { throw ParsingApiJsonError(message: 'nft_main_repo -> getNfts: $e'); } } + + /// Ensures that the parent coins for the provided NFT chains are activated. + /// + /// TODO: Migrate NFT functionality to the SDK. This is a temporary measure + /// during the transition period. + Future _activateParentCoins(List chains) async { + final List knownCoins = _coinsRepo.getKnownCoins(); + final List parentCoins = chains + .map((NftBlockchains chain) { + return knownCoins + .firstWhereOrNull((Coin coin) => coin.id.id == chain.coinAbbr()); + }) + .whereType() + .toList(); + + if (parentCoins.isEmpty) { + return; + } + + try { + await _coinsRepo.activateCoinsSync(parentCoins, maxRetryAttempts: 10); + } catch (e, s) { + _log.shout('Failed to activate parent coins', e, s); + } + } } diff --git a/lib/mm2/mm2_api/mm2_api_nft.dart b/lib/mm2/mm2_api/mm2_api_nft.dart index 9cbf7ab69e..42e180d12e 100644 --- a/lib/mm2/mm2_api/mm2_api_nft.dart +++ b/lib/mm2/mm2_api/mm2_api_nft.dart @@ -1,7 +1,3 @@ -// TODO: update [TransportError] and [BaseError] to either use SDK exceptions -// or to at least extend the Exception class -// ignore_for_file: only_throw_errors - import 'dart:convert'; import 'package:http/http.dart'; @@ -38,7 +34,6 @@ class Mm2ApiNft { 'while your NFTs are loaded.', }; } - await _enableNftChains(chains); final request = UpdateNftRequest(chains: nftChains); return await call(request); @@ -125,33 +120,45 @@ class Mm2ApiNft { } } + /// Returns a list of the [chains] that are currently active in KDF via the SDK. + /// This is used to ensure that the NFT functionality only operates on + /// chains that are activated in the SDK. + /// If no chains are active, an empty list is returned. Future> getActiveNftChains(List chains) async { final List apiCoins = await _sdk.assets.getActivatedAssets(); final List enabledCoinIds = apiCoins.map((c) => c.id.id).toList(); _log.fine('enabledCoinIds: $enabledCoinIds'); final List nftCoins = chains.map((c) => c.coinAbbr()).toList(); _log.fine('nftCoins: $nftCoins'); + final List activeChains = chains .map((c) => c) .toList() .where((c) => enabledCoinIds.contains(c.coinAbbr())) .toList(); _log.fine('activeChains: $activeChains'); + final List nftChains = activeChains.map((c) => c.toApiRequest()).toList(); _log.fine('nftChains: $nftChains'); + return nftChains; } Future enableNft(Asset asset) async { - final configSymbol = asset.id.symbol.configSymbol; + final configSymbol = asset.id.symbol.assetConfigId; final activationParams = NftActivationParams(provider: NftProvider.moralis()); - await _sdk.client.rpc.nft - .enableNft(ticker: configSymbol, activationParams: activationParams); + await retry( + () async => await _sdk.client.rpc.nft + .enableNft(ticker: configSymbol, activationParams: activationParams), + maxAttempts: 3, + backoffStrategy: + ExponentialBackoff(initialDelay: const Duration(seconds: 1)), + ); } - Future _enableNftChains( + Future enableNftChains( List chains, ) async { final knownAssets = _sdk.assets.available; @@ -166,12 +173,26 @@ class Mm2ApiNft { .firstWhere((asset) => asset.id.id == chain.nftAssetTicker()), ) .toList(); + if (inactiveChains.isEmpty) { return; } + // Attempt to enable all inactive NFT chains, logging any errors. + // but not throwing them immediately, so we can try to enable all chains. + // If any chain fails, we will throw the last error encountered. + Exception? lastError; for (final chain in inactiveChains) { - await enableNft(chain); + try { + await enableNft(chain); + } catch (e) { + _log.shout('Failed to enable NFT chain: ${chain.id.id}', e); + lastError = e as Exception; + } + } + + if (lastError != null) { + throw lastError; } } } diff --git a/packages/komodo_ui_kit/lib/src/skeleton_loaders/skeleton_loader_list_tile.dart b/packages/komodo_ui_kit/lib/src/skeleton_loaders/skeleton_loader_list_tile.dart index 100939d5e6..7768b82c24 100644 --- a/packages/komodo_ui_kit/lib/src/skeleton_loaders/skeleton_loader_list_tile.dart +++ b/packages/komodo_ui_kit/lib/src/skeleton_loaders/skeleton_loader_list_tile.dart @@ -1,7 +1,12 @@ import 'package:flutter/material.dart'; class SkeletonListTile extends StatefulWidget { - const SkeletonListTile({super.key}); + const SkeletonListTile({ + super.key, + this.height = 122, + }); + + final double height; @override State createState() => _SkeletonListTileState(); @@ -49,6 +54,7 @@ class _SkeletonListTileState extends State @override Widget build(BuildContext context) { return Container( + height: widget.height, padding: const EdgeInsets.all(16), child: Row( children: [