diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index f84a4524e3..700b62cf9b 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -406,7 +406,7 @@ "$(PROJECT_DIR)", ); MACOSX_DEPLOYMENT_TARGET = 15.0; - PRODUCT_BUNDLE_IDENTIFIER = com.komodoplatform.atomicdex; + PRODUCT_BUNDLE_IDENTIFIER = com.komodo.wallet; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -549,7 +549,7 @@ "$(PROJECT_DIR)", ); MACOSX_DEPLOYMENT_TARGET = 15.0; - PRODUCT_BUNDLE_IDENTIFIER = com.komodoplatform.atomicdex; + PRODUCT_BUNDLE_IDENTIFIER = com.komodo.wallet; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -584,7 +584,7 @@ "$(PROJECT_DIR)", ); MACOSX_DEPLOYMENT_TARGET = 15.0; - PRODUCT_BUNDLE_IDENTIFIER = com.komodoplatform.atomicdex; + PRODUCT_BUNDLE_IDENTIFIER = com.komodo.wallet; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; diff --git a/lib/bloc/app_bloc_root.dart b/lib/bloc/app_bloc_root.dart index b06509b3eb..8312313582 100644 --- a/lib/bloc/app_bloc_root.dart +++ b/lib/bloc/app_bloc_root.dart @@ -48,7 +48,6 @@ import 'package:web_dex/bloc/system_health/system_health_bloc.dart'; import 'package:web_dex/bloc/taker_form/taker_bloc.dart'; import 'package:web_dex/bloc/trading_status/trading_status_bloc.dart'; import 'package:web_dex/bloc/trading_status/trading_status_service.dart'; -import 'package:web_dex/bloc/transaction_history/transaction_history_bloc.dart'; import 'package:web_dex/bloc/transaction_history/transaction_history_repo.dart'; import 'package:web_dex/bloc/version_info/version_info_bloc.dart'; import 'package:web_dex/blocs/kmd_rewards_bloc.dart'; @@ -211,10 +210,6 @@ class AppBlocRoot extends StatelessWidget { sdk: komodoDefiSdk, ), ), - BlocProvider( - create: (BuildContext ctx) => - TransactionHistoryBloc(sdk: komodoDefiSdk), - ), BlocProvider( create: (context) => SettingsBloc(storedPrefs, SettingsRepository()), diff --git a/lib/bloc/assets_overview/bloc/asset_overview_bloc.dart b/lib/bloc/assets_overview/bloc/asset_overview_bloc.dart index 48f942c1cc..28d00110d4 100644 --- a/lib/bloc/assets_overview/bloc/asset_overview_bloc.dart +++ b/lib/bloc/assets_overview/bloc/asset_overview_bloc.dart @@ -11,6 +11,7 @@ import 'package:web_dex/bloc/cex_market_data/profit_loss/profit_loss_repository. import 'package:web_dex/bloc/cex_market_data/sdk_auth_activation_extension.dart'; import 'package:web_dex/bloc/coins_bloc/asset_coin_extension.dart'; import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/constants.dart'; part 'asset_overview_event.dart'; part 'asset_overview_state.dart'; @@ -100,7 +101,10 @@ class AssetOverviewBloc extends Bloc { return; } - await _sdk.waitForEnabledCoinsToPassThreshold(supportedCoins); + await _sdk.waitForEnabledCoinsToPassThreshold( + supportedCoins, + delay: kActivationPollingInterval, + ); final activeCoins = await supportedCoins.removeInactiveCoins(_sdk); if (activeCoins.isEmpty) { diff --git a/lib/bloc/bridge_form/bridge_validator.dart b/lib/bloc/bridge_form/bridge_validator.dart index 0e5c59d1b1..75839d200e 100644 --- a/lib/bloc/bridge_form/bridge_validator.dart +++ b/lib/bloc/bridge_form/bridge_validator.dart @@ -28,11 +28,11 @@ class BridgeValidator { required CoinsRepo coinsRepository, required DexRepository dexRepository, required KomodoDefiSdk sdk, - }) : _bloc = bloc, - _coinsRepo = coinsRepository, - _dexRepo = dexRepository, - _sdk = sdk, - _add = bloc.add; + }) : _bloc = bloc, + _coinsRepo = coinsRepository, + _dexRepo = dexRepository, + _sdk = sdk, + _add = bloc.add; final BridgeBloc _bloc; final CoinsRepo _coinsRepo; @@ -71,32 +71,32 @@ class BridgeValidator { } DexFormError? _parsePreimageError( - DataFromService preimageData) { + DataFromService preimageData, + ) { final BaseError? error = preimageData.error; if (error is TradePreimageNotSufficientBalanceError) { return _insufficientBalanceError( - Rational.parse(error.required), error.coin); + Rational.parse(error.required), + error.coin, + ); } else if (error is TradePreimageNotSufficientBaseCoinBalanceError) { return _insufficientBalanceError( - Rational.parse(error.required), error.coin); - } else if (error is TradePreimageTransportError) { - return DexFormError( - error: LocaleKeys.notEnoughBalanceForGasError.tr(), + Rational.parse(error.required), + error.coin, ); + } else if (error is TradePreimageTransportError) { + return DexFormError(error: LocaleKeys.notEnoughBalanceForGasError.tr()); } else if (error is TradePreimageVolumeTooLowError) { return DexFormError( - error: LocaleKeys.lowTradeVolumeError - .tr(args: [formatAmt(double.parse(error.threshold)), error.coin]), + error: LocaleKeys.lowTradeVolumeError.tr( + args: [formatAmt(double.parse(error.threshold)), error.coin], + ), ); } else if (error != null) { - return DexFormError( - error: error.message, - ); + return DexFormError(error: error.message); } else if (preimageData.data == null) { - return DexFormError( - error: LocaleKeys.somethingWrong.tr(), - ); + return DexFormError(error: LocaleKeys.somethingWrong.tr()); } return null; @@ -128,10 +128,15 @@ class BridgeValidator { _state.sellAmount, ); } catch (e, s) { - log(e.toString(), - trace: s, path: 'bridge_validator::_getPreimageData', isError: true); + log( + e.toString(), + trace: s, + path: 'bridge_validator::_getPreimageData', + isError: true, + ); return DataFromService( - error: TextError(error: 'Failed to request trade preimage')); + error: TextError(error: 'Failed to request trade preimage'), + ); } } @@ -187,17 +192,17 @@ class BridgeValidator { if (availableBalance < maxOrderVolume && sellAmount > availableBalance) { final Rational minAmount = maxRational([ _state.minSellAmount ?? Rational.zero, - _state.bestOrder!.minVolume + _state.bestOrder!.minVolume, ])!; if (availableBalance < minAmount) { - _add(BridgeSetError( - _insufficientBalanceError(minAmount, _state.sellCoin!.abbr), - )); + _add( + BridgeSetError( + _insufficientBalanceError(minAmount, _state.sellCoin!.abbr), + ), + ); } else { - _add(BridgeSetError( - _setMaxError(availableBalance), - )); + _add(BridgeSetError(_setMaxError(availableBalance))); } return false; @@ -218,9 +223,11 @@ class BridgeValidator { if (sellAmount < minAmount) { final Rational available = _state.maxSellAmount ?? Rational.zero; if (available < minAmount) { - _add(BridgeSetError( - _insufficientBalanceError(minAmount, _state.sellCoin!.abbr), - )); + _add( + BridgeSetError( + _insufficientBalanceError(minAmount, _state.sellCoin!.abbr), + ), + ); } else { _add(BridgeSetError(_setMinError(minAmount))); } @@ -233,22 +240,17 @@ class BridgeValidator { Future _validateCoinAndParent(String abbr) async { final coin = _sdk.getSdkAsset(abbr); - final enabledAssets = await _sdk.assets.getActivatedAssets(); - final isAssetEnabled = enabledAssets.contains(coin); + final activatedAssetIds = await _coinsRepo.getActivatedAssetIds(); final parentId = coin.id.parentId; - final parent = _sdk.assets.available[parentId]; - if (!isAssetEnabled) { + if (!activatedAssetIds.contains(coin.id)) { _add(BridgeSetError(_coinNotActiveError(coin.id.id))); return false; } - if (parent != null) { - final isParentEnabled = enabledAssets.contains(parent); - if (!isParentEnabled) { - _add(BridgeSetError(_coinNotActiveError(parent.id.id))); - return false; - } + if (parentId != null && !activatedAssetIds.contains(parentId)) { + _add(BridgeSetError(_coinNotActiveError(parentId.id))); + return false; } return true; @@ -262,7 +264,8 @@ class BridgeValidator { final selectedOrderAddress = selectedOrder.address; final asset = _sdk.getSdkAsset(selectedOrder.coin); - final ownPubkeys = await _sdk.pubkeys.getPubkeys(asset); + final cached = _sdk.pubkeys.lastKnown(asset.id); + final ownPubkeys = cached ?? await _sdk.pubkeys.getPubkeys(asset); final ownAddresses = ownPubkeys.keys .where((pubkeyInfo) => pubkeyInfo.isActiveForSwap) .map((e) => e.address) @@ -304,8 +307,9 @@ class BridgeValidator { DexFormError _setOrderMaxError(Rational maxAmount) { return DexFormError( - error: LocaleKeys.dexMaxOrderVolume - .tr(args: [formatDexAmt(maxAmount), _state.sellCoin!.abbr]), + error: LocaleKeys.dexMaxOrderVolume.tr( + args: [formatDexAmt(maxAmount), _state.sellCoin!.abbr], + ), type: DexFormErrorType.largerMaxSellVolume, action: DexFormErrorAction( text: LocaleKeys.setMax.tr(), @@ -318,8 +322,9 @@ class BridgeValidator { DexFormError _insufficientBalanceError(Rational required, String abbr) { return DexFormError( - error: LocaleKeys.dexBalanceNotSufficientError - .tr(args: [abbr, formatDexAmt(required), abbr]), + error: LocaleKeys.dexBalanceNotSufficientError.tr( + args: [abbr, formatDexAmt(required), abbr], + ), ); } @@ -341,20 +346,20 @@ class BridgeValidator { DexFormError _setMinError(Rational minAmount) { return DexFormError( type: DexFormErrorType.lessMinVolume, - error: LocaleKeys.dexMinSellAmountError - .tr(args: [formatDexAmt(minAmount), _state.sellCoin!.abbr]), + error: LocaleKeys.dexMinSellAmountError.tr( + args: [formatDexAmt(minAmount), _state.sellCoin!.abbr], + ), action: DexFormErrorAction( - text: LocaleKeys.setMin.tr(), - callback: () async { - _add(BridgeSetSellAmount(minAmount)); - }), + text: LocaleKeys.setMin.tr(), + callback: () async { + _add(BridgeSetSellAmount(minAmount)); + }, + ), ); } DexFormError _tradingWithSelfError() { - return DexFormError( - error: LocaleKeys.dexTradingWithSelfError.tr(), - ); + return DexFormError(error: LocaleKeys.dexTradingWithSelfError.tr()); } bool get _isSellCoinSelected => _state.sellCoin != null; 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 e9f3747cf2..339021156b 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 @@ -13,6 +13,7 @@ import 'package:web_dex/bloc/coins_bloc/asset_coin_extension.dart'; import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/text_error.dart'; +import 'package:web_dex/shared/constants.dart'; part 'portfolio_growth_event.dart'; part 'portfolio_growth_state.dart'; @@ -160,7 +161,10 @@ class PortfolioGrowthBloc // In case most coins are activating on wallet startup, wait for at least // 50% of the coins to be enabled before attempting to load the uncached // chart. - await _sdk.waitForEnabledCoinsToPassThreshold(filteredEventCoins); + await _sdk.waitForEnabledCoinsToPassThreshold( + filteredEventCoins, + delay: kActivationPollingInterval, + ); // Only remove inactivate/activating coins after an attempt to load the // cached chart, as the cached chart may contain inactive coins. 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 b382058728..540d7e4542 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 @@ -14,6 +14,7 @@ import 'package:web_dex/bloc/coins_bloc/asset_coin_extension.dart'; import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/text_error.dart'; +import 'package:web_dex/shared/constants.dart'; part 'profit_loss_event.dart'; part 'profit_loss_state.dart'; @@ -80,7 +81,10 @@ class ProfitLossBloc extends Bloc { // Fetch the un-cached version of the chart to update the cache. if (supportedCoins.isNotEmpty) { - await _sdk.waitForEnabledCoinsToPassThreshold(supportedCoins); + await _sdk.waitForEnabledCoinsToPassThreshold( + supportedCoins, + delay: kActivationPollingInterval, + ); } final activeCoins = await supportedCoins.removeInactiveCoins(_sdk); if (activeCoins.isNotEmpty) { diff --git a/lib/bloc/cex_market_data/sdk_auth_activation_extension.dart b/lib/bloc/cex_market_data/sdk_auth_activation_extension.dart index 48c00d2319..5ff7138207 100644 --- a/lib/bloc/cex_market_data/sdk_auth_activation_extension.dart +++ b/lib/bloc/cex_market_data/sdk_auth_activation_extension.dart @@ -2,6 +2,7 @@ import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:logging/logging.dart'; import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/constants.dart'; extension SdkAuthActivationExtension on KomodoDefiSdk { /// Waits for the enabled coins to pass the provided threshold of the provided @@ -14,7 +15,7 @@ extension SdkAuthActivationExtension on KomodoDefiSdk { List walletCoins, { double threshold = 0.5, Duration timeout = const Duration(seconds: 30), - Duration delay = const Duration(milliseconds: 500), + Duration delay = kActivationPollingInterval, }) async { if (timeout <= Duration.zero) { throw ArgumentError.value(timeout, 'timeout', 'is negative'); @@ -27,8 +28,10 @@ extension SdkAuthActivationExtension on KomodoDefiSdk { final walletCoinIds = walletCoins.map((e) => e.id).toSet(); final stopwatch = Stopwatch()..start(); while (true) { - final isAboveThreshold = - await _areEnabledCoinsAboveThreshold(walletCoinIds, threshold); + final isAboveThreshold = await _areEnabledCoinsAboveThreshold( + walletCoinIds, + threshold, + ); if (isAboveThreshold) { log.fine( 'Enabled coins have passed the threshold in ' diff --git a/lib/bloc/coin_addresses/bloc/coin_addresses_bloc.dart b/lib/bloc/coin_addresses/bloc/coin_addresses_bloc.dart index bf43c9aa80..68d643f7af 100644 --- a/lib/bloc/coin_addresses/bloc/coin_addresses_bloc.dart +++ b/lib/bloc/coin_addresses/bloc/coin_addresses_bloc.dart @@ -119,7 +119,9 @@ class CoinAddressesBloc extends Bloc { try { final asset = getSdkAsset(sdk, assetId); - final addresses = (await asset.getPubkeys(sdk)).keys; + // Prefer cached pubkeys to avoid unnecessary RPC delay + final cached = sdk.pubkeys.lastKnown(asset.id); + final addresses = (cached ?? await asset.getPubkeys(sdk)).keys; final reasons = await asset.getCantCreateNewAddressReasons(sdk); diff --git a/lib/bloc/coins_bloc/asset_coin_extension.dart b/lib/bloc/coins_bloc/asset_coin_extension.dart index f1eef13e7c..d8044b72fe 100644 --- a/lib/bloc/coins_bloc/asset_coin_extension.dart +++ b/lib/bloc/coins_bloc/asset_coin_extension.dart @@ -236,26 +236,6 @@ extension AssetBalanceExtension on Coin { } } -extension AssetListOps on List { - Future> removeInactiveAssets(KomodoDefiSdk sdk) async { - final activeAssets = await sdk.assets.getActivatedAssets(); - final activeAssetsMap = activeAssets.map((e) => e.id).toSet(); - - return where( - (asset) => activeAssetsMap.contains(asset.id), - ).unmodifiable().toList(); - } - - Future> removeActiveAssets(KomodoDefiSdk sdk) async { - final activeAssets = await sdk.assets.getActivatedAssets(); - final activeAssetsMap = activeAssets.map((e) => e.id).toSet(); - - return where( - (asset) => !activeAssetsMap.contains(asset.id), - ).unmodifiable().toList(); - } -} - extension CoinSupportOps on Iterable { /// Returns a list excluding test coins. Useful when filtering coins before /// running portfolio calculations that assume production assets only. @@ -282,20 +262,16 @@ extension CoinSupportOps on Iterable { static Future _alwaysSupported(Coin _) async => true; Future> removeInactiveCoins(KomodoDefiSdk sdk) async { - final activeCoins = await sdk.assets.getActivatedAssets(); - final activeCoinsMap = activeCoins.map((e) => e.id).toSet(); + final activeIds = await sdk.activatedAssetsCache.getActivatedAssetIds(); - return where( - (coin) => activeCoinsMap.contains(coin.id), - ).unmodifiable().toList(); + return where((coin) => activeIds.contains(coin.id)).unmodifiable().toList(); } Future> removeActiveCoins(KomodoDefiSdk sdk) async { - final activeCoins = await sdk.assets.getActivatedAssets(); - final activeCoinsMap = activeCoins.map((e) => e.id).toSet(); + final activeIds = await sdk.activatedAssetsCache.getActivatedAssetIds(); return where( - (coin) => !activeCoinsMap.contains(coin.id), + (coin) => !activeIds.contains(coin.id), ).unmodifiable().toList(); } diff --git a/lib/bloc/coins_bloc/coins_bloc.dart b/lib/bloc/coins_bloc/coins_bloc.dart index 5c8a18b73c..8721b25cf6 100644 --- a/lib/bloc/coins_bloc/coins_bloc.dart +++ b/lib/bloc/coins_bloc/coins_bloc.dart @@ -3,12 +3,11 @@ import 'dart:async'; import 'package:bloc/bloc.dart'; import 'package:bloc_concurrency/bloc_concurrency.dart'; import 'package:equatable/equatable.dart'; -import 'package:flutter/foundation.dart' show mapEquals; +import 'package:collection/collection.dart' show MapEquality; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:logging/logging.dart'; import 'package:web_dex/app_config/app_config.dart'; -import 'package:web_dex/bloc/cex_market_data/sdk_auth_activation_extension.dart'; import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; import 'package:web_dex/bloc/trading_status/trading_status_service.dart'; import 'package:web_dex/model/cex_price.dart'; @@ -34,6 +33,7 @@ class CoinsBloc extends Bloc { on(_onLogin, transformer: restartable()); on(_onLogout, transformer: restartable()); on(_onWalletCoinUpdated, transformer: sequential()); + on(_onBalanceChanged, transformer: droppable()); on( _onCoinsPubkeysRequested, transformer: concurrent(), @@ -47,6 +47,7 @@ class CoinsBloc extends Bloc { final _log = Logger('CoinsBloc'); StreamSubscription? _enabledCoinsSubscription; + StreamSubscription? _balanceChangesSubscription; Timer? _updateBalancesTimer; Timer? _updatePricesTimer; bool _isInitialActivationInProgress = false; @@ -54,6 +55,7 @@ class CoinsBloc extends Bloc { @override Future close() async { await _enabledCoinsSubscription?.cancel(); + await _balanceChangesSubscription?.cancel(); _updateBalancesTimer?.cancel(); _updatePricesTimer?.cancel(); @@ -120,15 +122,14 @@ class CoinsBloc extends Bloc { add(CoinsPricesUpdated()); _updatePricesTimer?.cancel(); - _updatePricesTimer = Timer.periodic( - const Duration(minutes: 1), - (_) { - if (kDebugElectrumLogs) { - _log.info('[POLLING] Triggering periodic price update (every 1 minute)'); - } - add(CoinsPricesUpdated()); - }, - ); + _updatePricesTimer = Timer.periodic(const Duration(minutes: 3), (_) { + if (kDebugElectrumLogs) { + _log.info( + '[POLLING] Triggering periodic price update (every 3 minutes)', + ); + } + add(CoinsPricesUpdated()); + }); // This is used to connect [CoinsBloc] to [CoinsManagerBloc] via [CoinsRepo], // since coins manager bloc activates and deactivates coins using the repository. @@ -139,6 +140,12 @@ class CoinsBloc extends Bloc { _enabledCoinsSubscription = _coinsRepo.enabledAssetsChanges.stream.listen( (Coin coin) => add(CoinsWalletCoinUpdated(coin)), ); + + // Subscribe to real-time balance changes from the repository + await _balanceChangesSubscription?.cancel(); + _balanceChangesSubscription = _coinsRepo.balanceChanges.stream.listen( + (Coin coin) => add(CoinsBalanceChanged(coin)), + ); } Future _onCoinsRefreshed( @@ -187,6 +194,27 @@ class CoinsBloc extends Bloc { } } + /// Real-time balance update handler + Future _onBalanceChanged( + CoinsBalanceChanged event, + Emitter emit, + ) async { + final updated = event.coin; + final assetId = updated.id.id; + final existing = state.walletCoins[assetId] ?? state.coins[assetId]; + if (existing == null) return; + + // Preserve persistent state fields such as activation state + final merged = updated.copyWith(state: existing.state); + + emit( + state.copyWith( + walletCoins: {...state.walletCoins, assetId: merged}, + coins: {...state.coins, assetId: merged}, + ), + ); + } + Future _onCoinsBalanceMonitoringStopped( CoinsBalanceMonitoringStopped event, Emitter emit, @@ -199,15 +227,14 @@ class CoinsBloc extends Bloc { Emitter emit, ) async { _updateBalancesTimer?.cancel(); - _updateBalancesTimer = Timer.periodic( - const Duration(minutes: 1), - (timer) { - if (kDebugElectrumLogs) { - _log.info('[POLLING] Triggering periodic balance refresh (every 1 minute)'); - } - add(CoinsBalancesRefreshed()); - }, - ); + _updateBalancesTimer = Timer.periodic(const Duration(minutes: 1), (timer) { + if (kDebugElectrumLogs) { + _log.info( + '[POLLING] Triggering periodic balance refresh (every 1 minute)', + ); + } + add(CoinsBalancesRefreshed()); + }); } Future _onCoinsActivated( @@ -290,7 +317,7 @@ class CoinsBloc extends Bloc { _log.severe('Coin prices list empty/null'); return; } - final didPricesChange = !mapEquals(state.prices, prices); + final didPricesChange = !const MapEquality().equals(state.prices, prices); if (!didPricesChange) { _log.info('Coin prices list unchanged'); return; @@ -385,50 +412,91 @@ class CoinsBloc extends Bloc { void _scheduleInitialBalanceRefresh(Iterable coinsToActivate) { if (isClosed) return; - final knownCoins = _coinsRepo.getKnownCoinsMap(); - final walletCoinsForThreshold = coinsToActivate - .map((coinId) => knownCoins[coinId]) - .whereType() - .toList(); - - if (walletCoinsForThreshold.isEmpty) { + final Set targetIds = coinsToActivate.toSet(); + if (targetIds.isEmpty) { add(CoinsBalancesRefreshed()); add(CoinsBalanceMonitoringStarted()); return; } unawaited(() async { + final stopwatch = Stopwatch()..start(); var triggeredByThreshold = false; + var fired = false; + + void _fire() { + if (fired || isClosed) return; + fired = true; + if (triggeredByThreshold) { + _log.fine( + 'Initial balance refresh triggered after 80% of coins activated.', + ); + } else { + _log.fine( + 'Initial balance refresh triggered after timeout while waiting for coin activation.', + ); + } + add(CoinsBalancesRefreshed()); + add(CoinsBalanceMonitoringStarted()); + } + + final activeIds = {}; + + // Seed with currently activated assets from the SDK cache try { - triggeredByThreshold = await _kdfSdk.waitForEnabledCoinsToPassThreshold( - walletCoinsForThreshold, - threshold: 0.8, - timeout: const Duration(minutes: 1), - ); - } catch (e, s) { - _log.shout( - 'Failed while waiting for enabled coins threshold during login', - e, - s, - ); + final activated = await _kdfSdk.activatedAssetsCache + .getActivatedAssetIds(forceRefresh: true); + for (final id in activated) { + if (targetIds.contains(id.id)) { + activeIds.add(id.id); + } + } + } catch (_) { + // Best-effort seeding; continue with streaming updates } - if (isClosed) { + bool _checkThreshold() { + if (targetIds.isEmpty) return true; + final coverage = activeIds.length / targetIds.length; + if (coverage >= 0.8) { + triggeredByThreshold = true; + return true; + } + return false; + } + + if (_checkThreshold()) { + _fire(); return; } - if (triggeredByThreshold) { - _log.fine( - 'Initial balance refresh triggered after 80% of coins activated.', - ); - } else { - _log.fine( - 'Initial balance refresh triggered after timeout while waiting for coin activation.', - ); + StreamSubscription? tempSub; + tempSub = _coinsRepo.enabledAssetsChanges.stream.listen((coin) { + if (isClosed || fired) return; + if (!targetIds.contains(coin.id.id)) return; + if (coin.isActive) { + activeIds.add(coin.id.id); + if (_checkThreshold()) { + final sub = tempSub; + tempSub = null; + sub?.cancel(); + _fire(); + } + } + }); + + // Fallback: timeout to avoid waiting indefinitely + const timeout = Duration(minutes: 1); + await Future.delayed(timeout); + final sub = tempSub; + tempSub = null; + await sub?.cancel(); + if (!fired) { + triggeredByThreshold = false; + _fire(); } - add(CoinsBalancesRefreshed()); - add(CoinsBalanceMonitoringStarted()); + stopwatch.stop(); }()); } diff --git a/lib/bloc/coins_bloc/coins_event.dart b/lib/bloc/coins_bloc/coins_event.dart index afa0982076..0a09c72a90 100644 --- a/lib/bloc/coins_bloc/coins_event.dart +++ b/lib/bloc/coins_bloc/coins_event.dart @@ -41,6 +41,16 @@ final class CoinsDeactivated extends CoinsEvent { final class CoinsPricesUpdated extends CoinsEvent {} +/// Emitted when a coin's balance has changed (real-time from SDK) +final class CoinsBalanceChanged extends CoinsEvent { + const CoinsBalanceChanged(this.coin); + + final Coin coin; + + @override + List get props => [coin]; +} + /// Successful user login (session) /// NOTE: has to be called from the UI layer for now, to ensure that wallet /// metadata is saved to the current user. Auth state changes from the SDK diff --git a/lib/bloc/coins_bloc/coins_repo.dart b/lib/bloc/coins_bloc/coins_repo.dart index a928206522..9907caacca 100644 --- a/lib/bloc/coins_bloc/coins_repo.dart +++ b/lib/bloc/coins_bloc/coins_repo.dart @@ -12,7 +12,8 @@ import 'package:komodo_defi_types/komodo_defi_type_utils.dart' import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:komodo_ui/komodo_ui.dart'; import 'package:logging/logging.dart'; -import 'package:web_dex/app_config/app_config.dart' show excludedAssetList, kDebugElectrumLogs; +import 'package:web_dex/app_config/app_config.dart' + show excludedAssetList, kDebugElectrumLogs; import 'package:web_dex/bloc/coins_bloc/asset_coin_extension.dart'; import 'package:web_dex/bloc/trading_status/trading_status_service.dart' show TradingStatusService; @@ -20,7 +21,6 @@ import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/mm2/mm2.dart'; import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; import 'package:web_dex/mm2/mm2_api/rpc/bloc_response.dart'; -import 'package:web_dex/mm2/mm2_api/rpc/disable_coin/disable_coin_req.dart'; import 'package:web_dex/mm2/mm2_api/rpc/withdraw/withdraw_errors.dart'; import 'package:web_dex/mm2/mm2_api/rpc/withdraw/withdraw_request.dart'; import 'package:web_dex/model/cex_price.dart'; @@ -54,6 +54,10 @@ class CoinsRepo { onListen: () => _enabledAssetListenerCount += 1, onCancel: () => _enabledAssetListenerCount -= 1, ); + balanceChanges = StreamController.broadcast( + onListen: () => _balanceListenerCount += 1, + onCancel: () => _balanceListenerCount -= 1, + ); } final KomodoDefiSdk _kdfSdk; @@ -97,6 +101,21 @@ class CoinsRepo { } } + /// Stream to broadcast real-time balance changes for coins + late final StreamController balanceChanges; + int _balanceListenerCount = 0; + bool get _balancesHasListeners => _balanceListenerCount > 0; + void _broadcastBalanceChange(Coin coin) { + if (_balancesHasListeners) { + balanceChanges.add(coin); + } else { + _log.fine( + 'No listeners for balanceChanges stream. ' + 'Skipping broadcast for ${coin.id.id}', + ); + } + } + Future balance(AssetId id) => _kdfSdk.balances.getBalance(id); BalanceInfo? lastKnownBalance(AssetId id) => _kdfSdk.balances.lastKnown(id); @@ -119,6 +138,9 @@ class CoinsRepo { balance: balanceInfo.total.toDouble(), spendable: balanceInfo.spendable.toDouble(), ); + + // Broadcast updated coin for UI to refresh via bloc + _broadcastBalanceChange(_assetToCoinWithoutAddress(asset)); }, ); } @@ -134,6 +156,7 @@ class CoinsRepo { subscription.cancel(); } _balanceWatchers.clear(); + _invalidateActivatedAssetsCache(); } void dispose() { @@ -143,6 +166,25 @@ class CoinsRepo { _balanceWatchers.clear(); enabledAssetsChanges.close(); + balanceChanges.close(); + } + + Future> getActivatedAssetIds({bool forceRefresh = false}) { + return _kdfSdk.activatedAssetsCache.getActivatedAssetIds( + forceRefresh: forceRefresh, + ); + } + + Future isAssetActivated( + AssetId assetId, { + bool forceRefresh = false, + }) async { + final activated = await getActivatedAssetIds(forceRefresh: forceRefresh); + return activated.contains(assetId); + } + + void _invalidateActivatedAssetsCache() { + _kdfSdk.activatedAssetsCache.invalidate(); } /// Returns all known coins, optionally filtering out excluded assets. @@ -306,7 +348,7 @@ class CoinsRepo { '[ACTIVATION] Starting activation of ${assets.length} coins: [$coinIdList]', ); _log.info('[ACTIVATION] Protocol breakdown: $protocolBreakdown'); - + // Log detailed parameters for each asset being activated for (final asset in assets) { _log.info( @@ -352,38 +394,40 @@ class CoinsRepo { for (final asset in assets) { final coin = _assetToCoinWithoutAddress(asset); try { - if (notifyListeners) { - _broadcastAsset(coin.copyWith(state: CoinState.activating)); - } + // Check if asset is already activated to avoid SDK exception. + // The SDK throws an exception when trying to activate an already-activated + // asset, so we need this manual check to prevent unnecessary retries. + final isAlreadyActivated = await isAssetActivated(asset.id); - // Use retry with exponential backoff for activation - await retry( - () async { - // exception is thrown if the asset is already activated, so manual - // check is needed for now until specific exception type can be caught - final activatedAssets = await _kdfSdk.assets.getActivatedAssets(); - if (activatedAssets.any((a) => a.id == asset.id)) { - _log.info( - 'Coin ${coin.id} is already activated. Skipping activation.', - ); - return; - } + if (isAlreadyActivated) { + _log.info( + 'Asset ${asset.id.id} is already activated. Skipping activation.', + ); + } else { + if (notifyListeners) { + _broadcastAsset(coin.copyWith(state: CoinState.activating)); + } - final progress = await _kdfSdk.assets.activateAsset(asset).last; - if (!progress.isSuccess) { - throw Exception( - progress.errorMessage ?? 'Activation failed for ${asset.id.id}', - ); - } - }, - maxAttempts: maxRetryAttempts, - backoffStrategy: ExponentialBackoff( - initialDelay: initialRetryDelay, - maxDelay: maxRetryDelay, - ), - ); + // Use retry with exponential backoff for activation + await retry( + () async { + final progress = await _kdfSdk.assets.activateAsset(asset).last; + if (!progress.isSuccess) { + throw Exception( + progress.errorMessage ?? + 'Activation failed for ${asset.id.id}', + ); + } + }, + maxAttempts: maxRetryAttempts, + backoffStrategy: ExponentialBackoff( + initialDelay: initialRetryDelay, + maxDelay: maxRetryDelay, + ), + ); - _log.info('Asset activated: ${asset.id.id}'); + _log.info('Asset activated: ${asset.id.id}'); + } if (kDebugElectrumLogs) { _log.info( '[ACTIVATION] Successfully activated ${asset.id.id} (${asset.protocol.runtimeType})', @@ -405,7 +449,9 @@ class CoinsRepo { } _subscribeToBalanceUpdates(asset); if (kDebugElectrumLogs) { - _log.info('[ACTIVATION] Subscribed to balance updates for ${asset.id.id}'); + _log.info( + '[ACTIVATION] Subscribed to balance updates for ${asset.id.id}', + ); } if (coin.id.parentId != null) { final parentAsset = _kdfSdk.assets.available[coin.id.parentId]; @@ -422,7 +468,7 @@ class CoinsRepo { e, s, ); - + // Capture FD snapshot when KDF asset activation fails if (PlatformTuner.isIOS) { try { @@ -435,7 +481,7 @@ class CoinsRepo { _log.warning('Failed to capture FD stats: $fdError'); } } - + if (notifyListeners) { _broadcastAsset(asset.toCoin().copyWith(state: CoinState.suspended)); } @@ -451,6 +497,9 @@ class CoinsRepo { } } + // Invalidate the activated assets cache once after processing all assets + _invalidateActivatedAssetsCache(); + // Rethrow the last activation exception if there was one if (lastActivationException != null) { throw lastActivationException; @@ -542,7 +591,8 @@ class CoinsRepo { final allCoinIds = {}; final allChildCoins = []; - final activatedAssets = await _kdfSdk.assets.getActivatedAssets(); + final activatedAssets = await _kdfSdk.activatedAssetsCache + .getActivatedAssets(); for (final coin in coins) { allCoinIds.add(coin.id.id); @@ -590,44 +640,7 @@ class CoinsRepo { ]; await Future.wait(deactivationTasks); await Future.wait([...parentCancelFutures, ...childCancelFutures]); - } - - Future _disableCoin(String coinId) async { - try { - await _mm2.call(DisableCoinReq(coin: coinId)); - } on Exception catch (e, s) { - _log.shout('Error disabling $coinId', e, s); - return; - } - } - - @Deprecated( - 'Use SDK pubkeys.getPubkeys instead and let the user ' - 'select from the available options.', - ) - Future getFirstPubkey(String coinId) async { - final assets = _kdfSdk.assets.findAssetsByConfigId(coinId); - if (assets.isEmpty) { - _log.warning( - 'Unable to fetch pubkey for coinId $coinId because the asset is no longer available.', - ); - return null; - } - - if (assets.length > 1) { - final assetIds = assets.map((asset) => asset.id.id).join(', '); - final message = - 'Multiple assets found for coinId $coinId while fetching pubkey: $assetIds'; - _log.shout(message); - throw StateError(message); - } - - final asset = assets.single; - final pubkeys = await _kdfSdk.pubkeys.getPubkeys(asset); - if (pubkeys.keys.isEmpty) { - return null; - } - return pubkeys.keys.first.address; + _invalidateActivatedAssetsCache(); } double? getUsdPriceByAmount(String amount, String coinAbbr) { @@ -814,7 +827,8 @@ class CoinsRepo { bool notifyListeners = true, bool addToWalletMetadata = true, }) async { - final activatedAssets = await _kdfSdk.assets.getActivatedAssets(); + final activatedAssets = await _kdfSdk.activatedAssetsCache + .getActivatedAssets(); for (final asset in assets) { final coin = coins.firstWhere((coin) => coin.id == asset.id); @@ -931,6 +945,7 @@ class CoinsRepo { NetworkImage(coin.logoImageUrl!), ); } + _invalidateActivatedAssetsCache(); }, error: (message) { _log.severe( diff --git a/lib/bloc/custom_token_import/bloc/custom_token_import_bloc.dart b/lib/bloc/custom_token_import/bloc/custom_token_import_bloc.dart index d5bdecb2b7..4b526a42f2 100644 --- a/lib/bloc/custom_token_import/bloc/custom_token_import_bloc.dart +++ b/lib/bloc/custom_token_import/bloc/custom_token_import_bloc.dart @@ -203,4 +203,10 @@ class CustomTokenImportBloc ); } } + + @override + Future close() async { + _repository.dispose(); + await super.close(); + } } diff --git a/lib/bloc/custom_token_import/data/custom_token_import_repository.dart b/lib/bloc/custom_token_import/data/custom_token_import_repository.dart index 74b783094d..2d2fc486c4 100644 --- a/lib/bloc/custom_token_import/data/custom_token_import_repository.dart +++ b/lib/bloc/custom_token_import/data/custom_token_import_repository.dart @@ -26,6 +26,9 @@ abstract class ICustomTokenImportRepository { /// Get the API name for the given coin subclass. String? getNetworkApiName(CoinSubClass coinType); + + /// Release any held resources. + void dispose(); } class KdfCustomTokenImportRepository implements ICustomTokenImportRepository { @@ -33,11 +36,13 @@ class KdfCustomTokenImportRepository implements ICustomTokenImportRepository { this._kdfSdk, this._coinsRepo, { http.Client? httpClient, - }) : _httpClient = httpClient ?? http.Client(); + }) : _httpClient = httpClient ?? http.Client(), + _ownsHttpClient = httpClient == null; final CoinsRepo _coinsRepo; final KomodoDefiSdk _kdfSdk; final http.Client _httpClient; + final bool _ownsHttpClient; final _log = Logger('KdfCustomTokenImportRepository'); @override @@ -205,6 +210,13 @@ class KdfCustomTokenImportRepository implements ICustomTokenImportRepository { return null; } } + + @override + void dispose() { + if (_ownsHttpClient) { + _httpClient.close(); + } + } } extension on Erc20Protocol { diff --git a/lib/bloc/fiat/fiat_onramp_form/fiat_form_bloc.dart b/lib/bloc/fiat/fiat_onramp_form/fiat_form_bloc.dart index 6e6bab7099..8abbf5f35f 100644 --- a/lib/bloc/fiat/fiat_onramp_form/fiat_form_bloc.dart +++ b/lib/bloc/fiat/fiat_onramp_form/fiat_form_bloc.dart @@ -123,11 +123,14 @@ class FiatFormBloc extends Bloc { final asset = event.selectedCoin.toAsset(_sdk); await _coinsRepo.activateAssetsSync([asset]); // TODO: increase the max delay in the SDK or make it adjustable - final assetPubkeys = await retry( - () async => _sdk.pubkeys.getPubkeys(asset), - maxAttempts: _pubkeysMaxRetryAttempts, - backoffStrategy: ConstantBackoff(delay: _pubkeysRetryDelay), - ); + AssetPubkeys? assetPubkeys = _sdk.pubkeys.lastKnown(asset.id); + if (assetPubkeys == null) { + assetPubkeys = await retry( + () async => _sdk.pubkeys.getPubkeys(asset), + maxAttempts: _pubkeysMaxRetryAttempts, + backoffStrategy: ConstantBackoff(delay: _pubkeysRetryDelay), + ); + } final address = assetPubkeys.keys.firstOrNull; emit( diff --git a/lib/bloc/nft_receive/bloc/nft_receive_bloc.dart b/lib/bloc/nft_receive/bloc/nft_receive_bloc.dart index 3a3cc4ba57..523df10eb4 100644 --- a/lib/bloc/nft_receive/bloc/nft_receive_bloc.dart +++ b/lib/bloc/nft_receive/bloc/nft_receive_bloc.dart @@ -11,12 +11,10 @@ part 'nft_receive_event.dart'; part 'nft_receive_state.dart'; class NftReceiveBloc extends Bloc { - NftReceiveBloc({ - required CoinsRepo coinsRepo, - required KomodoDefiSdk sdk, - }) : _coinsRepo = coinsRepo, - _sdk = sdk, - super(NftReceiveInitial()) { + NftReceiveBloc({required CoinsRepo coinsRepo, required KomodoDefiSdk sdk}) + : _coinsRepo = coinsRepo, + _sdk = sdk, + super(NftReceiveInitial()) { on(_onInitial); on(_onRefresh); on(_onChangeAddress); @@ -47,13 +45,13 @@ class NftReceiveBloc extends Bloc { final walletConfig = (await _sdk.currentWallet())?.config; if (walletConfig?.hasBackup == false && !coin.isTestCoin) { _log.warning('Wallet does not have backup and is not a test coin'); - return emit( - NftReceiveBackupSuccess(), - ); + return emit(NftReceiveBackupSuccess()); } final asset = _sdk.assets.available[coin.id]!; - final pubkeys = await _sdk.pubkeys.getPubkeys(asset); + final pubkeys = + _sdk.pubkeys.lastKnown(asset.id) ?? + await _sdk.pubkeys.getPubkeys(asset); if (pubkeys.keys.isEmpty) { _log.warning('No pubkey found for the asset: ${coin.id.id}'); return emit( diff --git a/lib/bloc/taker_form/taker_validator.dart b/lib/bloc/taker_form/taker_validator.dart index e636fd59fc..40a51fb6f1 100644 --- a/lib/bloc/taker_form/taker_validator.dart +++ b/lib/bloc/taker_form/taker_validator.dart @@ -28,10 +28,10 @@ class TakerValidator { required CoinsRepo coinsRepo, required DexRepository dexRepo, required KomodoDefiSdk sdk, - }) : _bloc = bloc, - _coinsRepo = coinsRepo, - _dexRepo = dexRepo, - _sdk = sdk, + }) : _bloc = bloc, + _coinsRepo = coinsRepo, + _dexRepo = dexRepo, + _sdk = sdk, add = bloc.add; final TakerBloc _bloc; @@ -71,32 +71,32 @@ class TakerValidator { } DexFormError? _parsePreimageError( - DataFromService preimageData) { + DataFromService preimageData, + ) { final BaseError? error = preimageData.error; if (error is TradePreimageNotSufficientBalanceError) { return _insufficientBalanceError( - Rational.parse(error.required), error.coin); + Rational.parse(error.required), + error.coin, + ); } else if (error is TradePreimageNotSufficientBaseCoinBalanceError) { return _insufficientBalanceError( - Rational.parse(error.required), error.coin); - } else if (error is TradePreimageTransportError) { - return DexFormError( - error: LocaleKeys.notEnoughBalanceForGasError.tr(), + Rational.parse(error.required), + error.coin, ); + } else if (error is TradePreimageTransportError) { + return DexFormError(error: LocaleKeys.notEnoughBalanceForGasError.tr()); } else if (error is TradePreimageVolumeTooLowError) { return DexFormError( - error: LocaleKeys.lowTradeVolumeError - .tr(args: [formatAmt(double.parse(error.threshold)), error.coin]), + error: LocaleKeys.lowTradeVolumeError.tr( + args: [formatAmt(double.parse(error.threshold)), error.coin], + ), ); } else if (error != null) { - return DexFormError( - error: error.message, - ); + return DexFormError(error: error.message); } else if (preimageData.data == null) { - return DexFormError( - error: LocaleKeys.somethingWrong.tr(), - ); + return DexFormError(error: LocaleKeys.somethingWrong.tr()); } return null; @@ -138,7 +138,8 @@ class TakerValidator { final selectedOrderAddress = selectedOrder.address; final asset = _sdk.getSdkAsset(selectedOrder.coin); - final ownPubkeys = await _sdk.pubkeys.getPubkeys(asset); + final cached = _sdk.pubkeys.lastKnown(asset.id); + final ownPubkeys = cached ?? await _sdk.pubkeys.getPubkeys(asset); final ownAddresses = ownPubkeys.keys .where((pubkeyInfo) => pubkeyInfo.isActiveForSwap) .map((e) => e.address) @@ -175,17 +176,17 @@ class TakerValidator { if (availableBalance < maxOrderVolume && sellAmount > availableBalance) { final Rational minAmount = maxRational([ state.minSellAmount ?? Rational.zero, - state.selectedOrder!.minVolume + state.selectedOrder!.minVolume, ])!; if (availableBalance < minAmount) { - add(TakerAddError( - _insufficientBalanceError(minAmount, state.sellCoin!.abbr), - )); + add( + TakerAddError( + _insufficientBalanceError(minAmount, state.sellCoin!.abbr), + ), + ); } else { - add(TakerAddError( - _setMaxError(availableBalance), - )); + add(TakerAddError(_setMaxError(availableBalance))); } return false; @@ -206,9 +207,11 @@ class TakerValidator { if (sellAmount < minAmount) { final Rational available = state.maxSellAmount ?? Rational.zero; if (available < minAmount) { - add(TakerAddError( - _insufficientBalanceError(minAmount, state.sellCoin!.abbr), - )); + add( + TakerAddError( + _insufficientBalanceError(minAmount, state.sellCoin!.abbr), + ), + ); } else { add(TakerAddError(_setMinError(minAmount))); } @@ -221,22 +224,17 @@ class TakerValidator { Future _validateCoinAndParent(String abbr) async { final coin = _sdk.getSdkAsset(abbr); - final enabledAssets = await _sdk.assets.getActivatedAssets(); - final isAssetEnabled = enabledAssets.contains(coin); + final activatedAssetIds = await _coinsRepo.getActivatedAssetIds(); final parentId = coin.id.parentId; - final parent = _sdk.assets.available[parentId]; - if (!isAssetEnabled) { + if (!activatedAssetIds.contains(coin.id)) { add(TakerAddError(_coinNotActiveError(coin.id.id))); return false; } - if (parent != null) { - final isParentEnabled = enabledAssets.contains(parent); - if (!isParentEnabled) { - add(TakerAddError(_coinNotActiveError(parent.id.id))); - return false; - } + if (parentId != null && !activatedAssetIds.contains(parentId)) { + add(TakerAddError(_coinNotActiveError(parentId.id))); + return false; } return true; @@ -308,10 +306,15 @@ class TakerValidator { state.sellAmount, ); } catch (e, s) { - log(e.toString(), - trace: s, path: 'taker_validator::_getPreimageData', isError: true); + log( + e.toString(), + trace: s, + path: 'taker_validator::_getPreimageData', + isError: true, + ); return DataFromService( - error: TextError(error: 'Failed to request trade preimage')); + error: TextError(error: 'Failed to request trade preimage'), + ); } } @@ -330,15 +333,17 @@ class TakerValidator { DexFormError _insufficientBalanceError(Rational required, String abbr) { return DexFormError( - error: LocaleKeys.dexBalanceNotSufficientError - .tr(args: [abbr, formatDexAmt(required), abbr]), + error: LocaleKeys.dexBalanceNotSufficientError.tr( + args: [abbr, formatDexAmt(required), abbr], + ), ); } DexFormError _setOrderMaxError(Rational maxAmount) { return DexFormError( - error: LocaleKeys.dexMaxOrderVolume - .tr(args: [formatDexAmt(maxAmount), state.sellCoin!.abbr]), + error: LocaleKeys.dexMaxOrderVolume.tr( + args: [formatDexAmt(maxAmount), state.sellCoin!.abbr], + ), type: DexFormErrorType.largerMaxSellVolume, action: DexFormErrorAction( text: LocaleKeys.setMax.tr(), @@ -367,19 +372,19 @@ class TakerValidator { DexFormError _setMinError(Rational minAmount) { return DexFormError( type: DexFormErrorType.lessMinVolume, - error: LocaleKeys.dexMinSellAmountError - .tr(args: [formatDexAmt(minAmount), state.sellCoin!.abbr]), + error: LocaleKeys.dexMinSellAmountError.tr( + args: [formatDexAmt(minAmount), state.sellCoin!.abbr], + ), action: DexFormErrorAction( - text: LocaleKeys.setMin.tr(), - callback: () async { - add(TakerSetSellAmount(minAmount)); - }), + text: LocaleKeys.setMin.tr(), + callback: () async { + add(TakerSetSellAmount(minAmount)); + }, + ), ); } DexFormError _tradingWithSelfError() { - return DexFormError( - error: LocaleKeys.dexTradingWithSelfError.tr(), - ); + return DexFormError(error: LocaleKeys.dexTradingWithSelfError.tr()); } } diff --git a/lib/bloc/transaction_history/transaction_history_bloc.dart b/lib/bloc/transaction_history/transaction_history_bloc.dart index f7c75e2ba2..c71f27d727 100644 --- a/lib/bloc/transaction_history/transaction_history_bloc.dart +++ b/lib/bloc/transaction_history/transaction_history_bloc.dart @@ -4,6 +4,7 @@ import 'package:bloc_concurrency/bloc_concurrency.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; +import 'package:komodo_defi_sdk/src/activation/activation_exceptions.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:web_dex/bloc/transaction_history/transaction_history_event.dart'; import 'package:web_dex/bloc/transaction_history/transaction_history_state.dart'; @@ -15,10 +16,9 @@ import 'package:web_dex/shared/utils/utils.dart'; class TransactionHistoryBloc extends Bloc { - TransactionHistoryBloc({ - required KomodoDefiSdk sdk, - }) : _sdk = sdk, - super(const TransactionHistoryState.initial()) { + TransactionHistoryBloc({required KomodoDefiSdk sdk}) + : _sdk = sdk, + super(const TransactionHistoryState.initial()) { on(_onSubscribe, transformer: restartable()); on(_onStartedLoading); on(_onUpdated); @@ -31,6 +31,9 @@ class TransactionHistoryBloc // TODO: Remove or move to SDK final Set _processedTxIds = {}; + // Stable in-memory clock for transactions that arrive with a zero timestamp. + // Ensures deterministic ordering of unconfirmed and just-confirmed items. + final Map _firstSeenAtById = {}; @override Future close() async { @@ -62,6 +65,7 @@ class TransactionHistoryBloc await _historySubscription?.cancel(); await _newTransactionsSubscription?.cancel(); _processedTxIds.clear(); + _firstSeenAtById.clear(); add(const TransactionHistoryStartedLoading()); final asset = _sdk.assets.available[event.coin.id]; @@ -69,51 +73,74 @@ class TransactionHistoryBloc throw Exception('Asset ${event.coin.id} not found in known coins list'); } - final pubkeys = await _sdk.pubkeys.getPubkeys(asset); + final pubkeys = + _sdk.pubkeys.lastKnown(asset.id) ?? + await _sdk.pubkeys.getPubkeys(asset); final myAddresses = pubkeys.keys.map((p) => p.address).toSet(); // Subscribe to historical transactions - _historySubscription = - _sdk.transactions.getTransactionsStreamed(asset).listen( - (newTransactions) { - // Filter out any transactions we've already processed - final uniqueTransactions = newTransactions.where((tx) { - final isNew = !_processedTxIds.contains(tx.internalId); - if (isNew) { - _processedTxIds.add(tx.internalId); - } - return isNew; - }).toList(); - - if (uniqueTransactions.isEmpty) return; - - final sanitized = - uniqueTransactions.map((tx) => tx.sanitize(myAddresses)).toList(); - final updatedTransactions = List.of(state.transactions) - ..addAll(sanitized) - ..sort(_sortTransactions); - - if (event.coin.isErcType) { - _flagTransactions(updatedTransactions, event.coin); - } - - add(TransactionHistoryUpdated(transactions: updatedTransactions)); - }, - onError: (error) { - add( - TransactionHistoryFailure( - error: TextError(error: LocaleKeys.somethingWrong.tr()), - ), + _historySubscription = _sdk.transactions + .getTransactionsStreamed(asset) + .listen( + (newTransactions) { + if (newTransactions.isEmpty) return; + + // Merge incoming batch by internalId, updating confirmations and other fields + final Map byId = { + for (final t in state.transactions) t.internalId: t, + }; + + for (final tx in newTransactions) { + final sanitized = tx.sanitize(myAddresses); + // Capture first-seen time for stable ordering where timestamp may be zero + _firstSeenAtById.putIfAbsent( + sanitized.internalId, + () => sanitized.timestamp.millisecondsSinceEpoch != 0 + ? sanitized.timestamp + : DateTime.now(), + ); + final existing = byId[sanitized.internalId]; + if (existing == null) { + byId[sanitized.internalId] = sanitized; + _processedTxIds.add(sanitized.internalId); + continue; + } + + // Update existing entry with fresher data (confirmations, blockHeight, fee, memo) + byId[sanitized.internalId] = existing.copyWith( + confirmations: sanitized.confirmations, + blockHeight: sanitized.blockHeight, + fee: sanitized.fee ?? existing.fee, + memo: sanitized.memo ?? existing.memo, + ); + } + + final updatedTransactions = byId.values.toList() + ..sort(_compareTransactions); + + if (event.coin.isErcType) { + _flagTransactions(updatedTransactions, event.coin); + } + + add(TransactionHistoryUpdated(transactions: updatedTransactions)); + }, + onError: (error) { + add( + TransactionHistoryFailure( + error: TextError(error: LocaleKeys.somethingWrong.tr()), + ), + ); + }, + onDone: () { + if (state.error == null && state.loading) { + add( + TransactionHistoryUpdated(transactions: state.transactions), + ); + } + // Once historical load is complete, start watching for new transactions + _subscribeToNewTransactions(asset, event.coin, myAddresses); + }, ); - }, - onDone: () { - if (state.error == null && state.loading) { - add(TransactionHistoryUpdated(transactions: state.transactions)); - } - // Once historical load is complete, start watching for new transactions - _subscribeToNewTransactions(asset, event.coin, myAddresses); - }, - ); } catch (e, s) { log( 'Error loading transaction history: $e', @@ -121,54 +148,84 @@ class TransactionHistoryBloc path: 'transaction_history_bloc->_onSubscribe', trace: s, ); - add( - TransactionHistoryFailure( - error: TextError(error: LocaleKeys.somethingWrong.tr()), - ), - ); + + String errorMessage; + if (e is ActivationFailedException) { + errorMessage = 'Asset activation failed: ${e.message}'; + } else { + errorMessage = LocaleKeys.somethingWrong.tr(); + } + + add(TransactionHistoryFailure(error: TextError(error: errorMessage))); } } void _subscribeToNewTransactions( - Asset asset, Coin coin, Set myAddresses) { - _newTransactionsSubscription = - _sdk.transactions.watchTransactions(asset).listen( - (newTransaction) { - if (_processedTxIds.contains(newTransaction.internalId)) return; - - _processedTxIds.add(newTransaction.internalId); - - final sanitized = newTransaction.sanitize(myAddresses); - final updatedTransactions = List.of(state.transactions) - ..add(sanitized) - ..sort(_sortTransactions); - - if (coin.isErcType) { - _flagTransactions(updatedTransactions, coin); - } - - add(TransactionHistoryUpdated(transactions: updatedTransactions)); - }, - onError: (error) { - add( - TransactionHistoryFailure( - error: TextError(error: LocaleKeys.somethingWrong.tr()), - ), + Asset asset, + Coin coin, + Set myAddresses, + ) { + _newTransactionsSubscription = _sdk.transactions + .watchTransactions(asset) + .listen( + (newTransaction) { + final sanitized = newTransaction.sanitize(myAddresses); + // Capture first-seen time once for stable ordering when timestamp is zero + _firstSeenAtById.putIfAbsent( + sanitized.internalId, + () => sanitized.timestamp.millisecondsSinceEpoch != 0 + ? sanitized.timestamp + : DateTime.now(), + ); + + // Merge single update by internalId + final Map byId = { + for (final t in state.transactions) t.internalId: t, + }; + + final existing = byId[sanitized.internalId]; + if (existing == null) { + byId[sanitized.internalId] = sanitized; + } else { + byId[sanitized.internalId] = existing.copyWith( + confirmations: sanitized.confirmations, + blockHeight: sanitized.blockHeight, + fee: sanitized.fee ?? existing.fee, + memo: sanitized.memo ?? existing.memo, + ); + } + + _processedTxIds.add(sanitized.internalId); + + final updatedTransactions = byId.values.toList() + ..sort(_compareTransactions); + + if (coin.isErcType) { + _flagTransactions(updatedTransactions, coin); + } + + add(TransactionHistoryUpdated(transactions: updatedTransactions)); + }, + onError: (error) { + String errorMessage; + if (error is ActivationFailedException) { + errorMessage = 'Asset activation failed: ${error.message}'; + } else { + errorMessage = LocaleKeys.somethingWrong.tr(); + } + + add( + TransactionHistoryFailure(error: TextError(error: errorMessage)), + ); + }, ); - }, - ); } void _onUpdated( TransactionHistoryUpdated event, Emitter emit, ) { - emit( - state.copyWith( - transactions: event.transactions, - loading: false, - ), - ); + emit(state.copyWith(transactions: event.transactions, loading: false)); } void _onStartedLoading( @@ -182,42 +239,55 @@ class TransactionHistoryBloc TransactionHistoryFailure event, Emitter emit, ) { - emit( - state.copyWith( - loading: false, - error: event.error, - ), - ); + emit(state.copyWith(loading: false, error: event.error)); } -} -int _sortTransactions(Transaction tx1, Transaction tx2) { - if (tx2.timestamp == DateTime.now()) { - return 1; - } else if (tx1.timestamp == DateTime.now()) { - return -1; + DateTime _sortTime(Transaction tx) { + if (tx.timestamp.millisecondsSinceEpoch != 0) return tx.timestamp; + final firstSeen = _firstSeenAtById[tx.internalId]; + return firstSeen ?? DateTime.fromMillisecondsSinceEpoch(0); + } + + int _compareTransactions(Transaction left, Transaction right) { + // Unconfirmed (pending) transactions should appear first. + final leftIsUnconfirmed = left.confirmations == 0; + final rightIsUnconfirmed = right.confirmations == 0; + + if (leftIsUnconfirmed != rightIsUnconfirmed) { + return leftIsUnconfirmed ? -1 : 1; + } + + // Within each group, sort by effective time (handles zero timestamps) + final timeComparison = _sortTime(right).compareTo(_sortTime(left)); + if (timeComparison != 0) return timeComparison; + + // Prefer higher block heights first + final heightComparison = right.blockHeight.compareTo(left.blockHeight); + if (heightComparison != 0) return heightComparison; + + // Final tiebreaker to ensure deterministic ordering + return right.internalId.compareTo(left.internalId); } - return tx2.timestamp.compareTo(tx1.timestamp); } +// Instance comparator now used; legacy top-level comparator removed. + void _flagTransactions(List transactions, Coin coin) { if (!coin.isErcType) return; - transactions - .removeWhere((tx) => tx.balanceChanges.totalAmount.toDouble() == 0.0); + transactions.removeWhere( + (tx) => tx.balanceChanges.totalAmount.toDouble() == 0.0, + ); } class Pagination { - Pagination({ - this.fromId, - this.pageNumber, - }); + Pagination({this.fromId, this.pageNumber}); final String? fromId; final int? pageNumber; Map toJson() => { - if (fromId != null) 'FromId': fromId, - if (pageNumber != null) 'PageNumber': pageNumber, - }; + if (fromId != null) 'FromId': fromId, + if (pageNumber != null) 'PageNumber': pageNumber, + }; } /// Represents different ways to paginate transaction history @@ -230,10 +300,7 @@ sealed class TransactionPagination { /// Standard page-based pagination class PagePagination extends TransactionPagination { - const PagePagination({ - required this.pageNumber, - required this.itemsPerPage, - }); + const PagePagination({required this.pageNumber, required this.itemsPerPage}); final int pageNumber; final int itemsPerPage; diff --git a/lib/bloc/withdraw_form/withdraw_form_bloc.dart b/lib/bloc/withdraw_form/withdraw_form_bloc.dart index 204b291ae8..8847ccfb14 100644 --- a/lib/bloc/withdraw_form/withdraw_form_bloc.dart +++ b/lib/bloc/withdraw_form/withdraw_form_bloc.dart @@ -59,7 +59,8 @@ class WithdrawFormBloc extends Bloc { Emitter emit, ) async { try { - final pubkeys = await state.asset.getPubkeys(_sdk); + final cached = _sdk.pubkeys.lastKnown(state.asset.id); + final pubkeys = cached ?? await state.asset.getPubkeys(_sdk); final fundedKeys = pubkeys.keys .where((key) => key.balance.spendable > Decimal.zero) .toList(); diff --git a/lib/blocs/maker_form_bloc.dart b/lib/blocs/maker_form_bloc.dart index e24bd86807..77420eb4fd 100644 --- a/lib/blocs/maker_form_bloc.dart +++ b/lib/blocs/maker_form_bloc.dart @@ -133,9 +133,9 @@ class MakerFormBloc implements BlocBase { _inBuyCoin.add(_buyCoin); if (coin == sellCoin && coin != null) sellCoin = null; - _autoActivate(buyCoin) - .then((_) => _updatePreimage()) - .then((_) => _reValidate()); + _autoActivate( + buyCoin, + ).then((_) => _updatePreimage()).then((_) => _reValidate()); } Rational? _sellAmount; @@ -250,8 +250,9 @@ class MakerFormBloc implements BlocBase { isMaxActive = false; await _updateMaxSellAmount(); - _maxSellAmountTimer = - Timer.periodic(const Duration(seconds: 10), (_) async { + _maxSellAmountTimer = Timer.periodic(const Duration(seconds: 10), ( + _, + ) async { await _updateMaxSellAmount(); }); } @@ -275,8 +276,7 @@ class MakerFormBloc implements BlocBase { return; } - final activeAssets = await kdfSdk.assets.getActivatedAssets(); - final isAssetActive = activeAssets.any((asset) => asset.id == coin.id); + final isAssetActive = await coinsRepository.isAssetActivated(coin.id); if (!isAssetActive) { // Intentionally leave in the loading state to avoid showing a "0.00" balance // while the asset is activating. @@ -386,35 +386,33 @@ class MakerFormBloc implements BlocBase { if (error is TradePreimageNotSufficientBalanceError) { _setFormErrors([ DexFormError( - error: LocaleKeys.dexBalanceNotSufficientError.tr(args: [ - error.coin, - formatAmt(double.parse(error.required)), - error.coin, - ]), - ) + error: LocaleKeys.dexBalanceNotSufficientError.tr( + args: [ + error.coin, + formatAmt(double.parse(error.required)), + error.coin, + ], + ), + ), ]); } else if (error is TradePreimageNotSufficientBaseCoinBalanceError) { _setFormErrors([ DexFormError( - error: LocaleKeys.dexBalanceNotSufficientError.tr(args: [ - error.coin, - formatAmt(double.parse(error.required)), - error.coin, - ]), - ) + error: LocaleKeys.dexBalanceNotSufficientError.tr( + args: [ + error.coin, + formatAmt(double.parse(error.required)), + error.coin, + ], + ), + ), ]); } else if (error is TradePreimageTransportError) { _setFormErrors([ - DexFormError( - error: LocaleKeys.notEnoughBalanceForGasError.tr(), - ) + DexFormError(error: LocaleKeys.notEnoughBalanceForGasError.tr()), ]); } else { - _setFormErrors([ - DexFormError( - error: error.message, - ) - ]); + _setFormErrors([DexFormError(error: error.message)]); } return false; @@ -439,13 +437,14 @@ class MakerFormBloc implements BlocBase { return DexFormError(error: LocaleKeys.dexSelectBuyCoinError.tr()); } else if (buyCoin.isSuspended) { return DexFormError( - error: LocaleKeys.dexCoinSuspendedError.tr(args: [buyCoin.abbr])); + error: LocaleKeys.dexCoinSuspendedError.tr(args: [buyCoin.abbr]), + ); } else { final Coin? parentCoin = buyCoin.parentCoin; if (parentCoin != null && parentCoin.isSuspended) { return DexFormError( - error: - LocaleKeys.dexCoinSuspendedError.tr(args: [parentCoin.abbr])); + error: LocaleKeys.dexCoinSuspendedError.tr(args: [parentCoin.abbr]), + ); } } @@ -468,13 +467,15 @@ class MakerFormBloc implements BlocBase { return DexFormError(error: LocaleKeys.dexSelectSellCoinError.tr()); } else if (sellCoin.isSuspended) { return DexFormError( - error: LocaleKeys.dexCoinSuspendedError.tr(args: [sellCoin.abbr])); + error: LocaleKeys.dexCoinSuspendedError.tr(args: [sellCoin.abbr]), + ); } final Coin? parentCoin = sellCoin.parentCoin; if (parentCoin != null && parentCoin.isSuspended) { return DexFormError( - error: LocaleKeys.dexCoinSuspendedError.tr(args: [parentCoin.abbr])); + error: LocaleKeys.dexCoinSuspendedError.tr(args: [parentCoin.abbr]), + ); } final Rational? sellAmount = this.sellAmount; @@ -490,14 +491,16 @@ class MakerFormBloc implements BlocBase { return DexFormError(error: LocaleKeys.notEnoughFundsError.tr()); } else if (sellAmount > maxAmount) { return DexFormError( - error: LocaleKeys.dexMaxSellAmountError - .tr(args: [formatAmt(maxAmount.toDouble()), sellCoin.abbr]), + error: LocaleKeys.dexMaxSellAmountError.tr( + args: [formatAmt(maxAmount.toDouble()), sellCoin.abbr], + ), type: DexFormErrorType.largerMaxSellVolume, action: DexFormErrorAction( - text: LocaleKeys.setMax.tr(), - callback: () async { - await setMaxSellAmount(); - }), + text: LocaleKeys.setMax.tr(), + callback: () async { + await setMaxSellAmount(); + }, + ), ); } } @@ -509,8 +512,10 @@ class MakerFormBloc implements BlocBase { Future _autoActivate(Coin? coin) async { if (coin == null || !await kdfSdk.auth.isSignedIn()) return; inProgress = true; - final List activationErrors = - await activateCoinIfNeeded(coin.abbr, coinsRepository); + final List activationErrors = await activateCoinIfNeeded( + coin.abbr, + coinsRepository, + ); inProgress = false; if (activationErrors.isNotEmpty) { _setFormErrors(activationErrors); @@ -518,13 +523,15 @@ class MakerFormBloc implements BlocBase { } Future makeOrder() async { - final Map? response = await api.setprice(SetPriceRequest( - base: sellCoin!.abbr, - rel: buyCoin!.abbr, - volume: sellAmount!, - price: price!, - max: isMaxActive, - )); + final Map? response = await api.setprice( + SetPriceRequest( + base: sellCoin!.abbr, + rel: buyCoin!.abbr, + volume: sellAmount!, + price: price!, + max: isMaxActive, + ), + ); if (response == null) { return TextError(error: LocaleKeys.somethingWrong.tr()); diff --git a/lib/blocs/trading_entities_bloc.dart b/lib/blocs/trading_entities_bloc.dart index 48ec3dff41..3850ccf4f1 100644 --- a/lib/blocs/trading_entities_bloc.dart +++ b/lib/blocs/trading_entities_bloc.dart @@ -77,7 +77,7 @@ class TradingEntitiesBloc implements BlocBase { void runUpdate() { bool updateInProgress = false; - timer = Timer.periodic(const Duration(seconds: 1), (_) async { + timer = Timer.periodic(const Duration(seconds: 10), (_) async { if (_closed) return; if (updateInProgress) return; // TODO!: do not run for hidden login or HW diff --git a/lib/main.dart b/lib/main.dart index 8306210caf..b48a7f60fb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -63,12 +63,12 @@ Future main() async { // The current focus is migrating mm2Api to the new sdk, so that the sdk // is the only/primary API/repository for KDF final KomodoDefiSdk komodoDefiSdk = await mm2.initialize(); - + // Configure SDK debug logging to match app configuration KdfApiClient.enableDebugLogging = kDebugElectrumLogs; KomodoDefiFramework.enableDebugLogging = kDebugElectrumLogs; - BalanceManager.enableDebugLogging = kDebugElectrumLogs; - + // BalanceManager.enableDebugLogging = kDebugElectrumLogs; + final mm2Api = Mm2Api(mm2: mm2, sdk: komodoDefiSdk); // Sparkline is dependent on Hive initialization, so we pass it on to the // bootstrapper here @@ -101,7 +101,9 @@ Future main() async { try { final result = await FdMonitorService().start(intervalSeconds: 60.0); if (result['success'] == true) { - log('FD Monitor started successfully in ${kDebugMode ? "DEBUG" : "RELEASE"} mode'); + log( + 'FD Monitor started successfully in ${kDebugMode ? "DEBUG" : "RELEASE"} mode', + ); } else { log('FD Monitor failed to start: ${result['message']}'); } diff --git a/lib/mm2/mm2_api/mm2_api.dart b/lib/mm2/mm2_api/mm2_api.dart index 12f9a03dac..c5f2114940 100644 --- a/lib/mm2/mm2_api/mm2_api.dart +++ b/lib/mm2/mm2_api/mm2_api.dart @@ -70,6 +70,10 @@ class Mm2Api { late Mm2ApiNft nft; VersionResponse? _versionResponse; + Future dispose() async { + nft.dispose(); + } + Future disableCoin(String coinId) async { try { await _mm2.call(DisableCoinReq(coin: coinId)); @@ -87,7 +91,8 @@ class Mm2Api { @Deprecated('Use balance from KomoDefiSdk instead') Future getBalance(String abbr) async { final sdkAsset = _sdk.assets.assetsFromTicker(abbr).single; - final addresses = await sdkAsset.getPubkeys(_sdk); + final cached = _sdk.pubkeys.lastKnown(sdkAsset.id); + final addresses = cached ?? await sdkAsset.getPubkeys(_sdk); return addresses.balance.total.toString(); } diff --git a/lib/mm2/mm2_api/mm2_api_nft.dart b/lib/mm2/mm2_api/mm2_api_nft.dart index 42e180d12e..a97a06eb8c 100644 --- a/lib/mm2/mm2_api/mm2_api_nft.dart +++ b/lib/mm2/mm2_api/mm2_api_nft.dart @@ -22,6 +22,11 @@ class Mm2ApiNft { final Future Function(dynamic) call; final _log = Logger('Mm2ApiNft'); + /// Dispose method for cleanup. Currently no-op as there are no resources to dispose. + void dispose() { + // No resources to dispose currently + } + Future> updateNftList( List chains, ) async { @@ -31,7 +36,7 @@ class Mm2ApiNft { return { 'error': 'Please ensure an NFT chain is activated and patiently await ' - 'while your NFTs are loaded.', + 'while your NFTs are loaded.', }; } final request = UpdateNftRequest(chains: nftChains); @@ -68,7 +73,7 @@ class Mm2ApiNft { return { 'error': 'Please ensure the NFT chain is activated and patiently await ' - 'while your NFTs are loaded.', + 'while your NFTs are loaded.', }; } @@ -111,8 +116,10 @@ class Mm2ApiNft { NftTxDetailsRequest request, ) async { try { - final additionalTxInfo = await const ProxyApiNft() - .getTxDetailsByHash(request.chain, request.txHash); + final additionalTxInfo = await const ProxyApiNft().getTxDetailsByHash( + request.chain, + request.txHash, + ); return additionalTxInfo; } catch (e, s) { _log.shout('Error getting nft tx details', e, s); @@ -125,8 +132,11 @@ class Mm2ApiNft { /// 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(); + final activatedAssets = await _sdk.activatedAssetsCache + .getActivatedAssets(); + final List enabledCoinIds = activatedAssets + .map((c) => c.id.id) + .toList(); _log.fine('enabledCoinIds: $enabledCoinIds'); final List nftCoins = chains.map((c) => c.coinAbbr()).toList(); _log.fine('nftCoins: $nftCoins'); @@ -138,8 +148,9 @@ class Mm2ApiNft { .toList(); _log.fine('activeChains: $activeChains'); - final List nftChains = - activeChains.map((c) => c.toApiRequest()).toList(); + final List nftChains = activeChains + .map((c) => c.toApiRequest()) + .toList(); _log.fine('nftChains: $nftChains'); return nftChains; @@ -147,30 +158,34 @@ class Mm2ApiNft { Future enableNft(Asset asset) async { final configSymbol = asset.id.symbol.assetConfigId; - final activationParams = - NftActivationParams(provider: NftProvider.moralis()); + final activationParams = NftActivationParams( + provider: NftProvider.moralis(), + ); await retry( - () async => await _sdk.client.rpc.nft - .enableNft(ticker: configSymbol, activationParams: activationParams), + () async => await _sdk.client.rpc.nft.enableNft( + ticker: configSymbol, + activationParams: activationParams, + ), maxAttempts: 3, - backoffStrategy: - ExponentialBackoff(initialDelay: const Duration(seconds: 1)), + backoffStrategy: ExponentialBackoff( + initialDelay: const Duration(seconds: 1), + ), ); } - Future enableNftChains( - List chains, - ) async { + Future enableNftChains(List chains) async { final knownAssets = _sdk.assets.available; - final activeAssets = await _sdk.assets.getActivatedAssets(); + final activeAssets = await _sdk.activatedAssetsCache.getActivatedAssets(); final inactiveChains = chains .where( - (chain) => !activeAssets - .any((asset) => asset.id.id == chain.nftAssetTicker()), + (chain) => !activeAssets.any( + (asset) => asset.id.id == chain.nftAssetTicker(), + ), ) .map( - (chain) => knownAssets.values - .firstWhere((asset) => asset.id.id == chain.nftAssetTicker()), + (chain) => knownAssets.values.firstWhere( + (asset) => asset.id.id == chain.nftAssetTicker(), + ), ) .toList(); @@ -201,8 +216,9 @@ class ProxyApiNft { const ProxyApiNft(); static const _errorBaseMessage = 'ProxyApiNft API: '; Future> addDetailsToTx(Map json) async { - final transactions = - List.from(json['result']['transfer_history'] as List? ?? []); + final transactions = List.from( + json['result']['transfer_history'] as List? ?? [], + ); final listOfAdditionalData = transactions .map( (tx) => { @@ -235,17 +251,11 @@ class ProxyApiNft { String txHash, ) async { final listOfAdditionalData = [ - { - 'blockchain': convertChainForProxy(blockchain), - 'tx_hash': txHash, - } + {'blockchain': convertChainForProxy(blockchain), 'tx_hash': txHash}, ]; final body = jsonEncode(listOfAdditionalData); try { - final response = await Client().post( - Uri.parse(txByHashUrl), - body: body, - ); + final response = await Client().post(Uri.parse(txByHashUrl), body: body); final jsonBody = jsonDecode(response.body) as JsonMap; return jsonBody[txHash] as JsonMap; } catch (e) { diff --git a/lib/mm2/rpc_native.dart b/lib/mm2/rpc_native.dart index 70d56fc979..d50d53c2ab 100644 --- a/lib/mm2/rpc_native.dart +++ b/lib/mm2/rpc_native.dart @@ -1,16 +1,25 @@ -import 'package:http/http.dart'; +import 'package:http/http.dart' as http; import 'package:web_dex/mm2/rpc.dart'; class RPCNative extends RPC { - RPCNative(); + RPCNative({http.Client? client}) + : _client = client ?? http.Client(), + _ownsClient = client == null; final Uri _url = Uri.parse('http://localhost:7783'); - final Client client = Client(); + final http.Client _client; + final bool _ownsClient; @override Future call(String reqStr) async { // todo: implement error handling - final Response response = await client.post(_url, body: reqStr); + final http.Response response = await _client.post(_url, body: reqStr); return response.body; } + + void dispose() { + if (_ownsClient) { + _client.close(); + } + } } diff --git a/lib/sdk/widgets/window_close_handler.dart b/lib/sdk/widgets/window_close_handler.dart index 1d4bc21f92..8623ae57bb 100644 --- a/lib/sdk/widgets/window_close_handler.dart +++ b/lib/sdk/widgets/window_close_handler.dart @@ -2,8 +2,11 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_window_close/flutter_window_close.dart'; +import 'package:get_it/get_it.dart'; +import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; import 'package:web_dex/app_config/app_config.dart'; import 'package:web_dex/mm2/mm2.dart'; +import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; import 'package:web_dex/shared/utils/platform_tuner.dart'; import 'package:web_dex/shared/utils/utils.dart'; import 'package:web_dex/shared/utils/window/window.dart'; @@ -21,10 +24,7 @@ class WindowCloseHandler extends StatefulWidget { /// Creates a WindowCloseHandler. /// /// The [child] parameter must not be null. - const WindowCloseHandler({ - super.key, - required this.child, - }); + const WindowCloseHandler({super.key, required this.child}); /// The widget below this widget in the tree. final Widget child; @@ -54,7 +54,8 @@ class _WindowCloseHandlerState extends State } else if (kIsWeb) { // Web platform: Use beforeunload event showMessageBeforeUnload( - 'This will close Komodo Wallet and stop all trading activities.'); + 'This will close Komodo Wallet and stop all trading activities.', + ); } else { // Mobile platforms: Use lifecycle observer WidgetsBinding.instance.addObserver(this); @@ -86,7 +87,8 @@ class _WindowCloseHandlerState extends State return AlertDialog( title: const Text('Do you really want to quit?'), content: const Text( - 'This will close Komodo Wallet and stop all trading activities.'), + 'This will close Komodo Wallet and stop all trading activities.', + ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), @@ -125,6 +127,17 @@ class _WindowCloseHandlerState extends State _hasSdkBeenDisposed = true; try { + final getIt = GetIt.I; + if (getIt.isRegistered()) { + await getIt().dispose(); + getIt.unregister(); + } + + if (getIt.isRegistered()) { + await getIt().dispose(); + getIt.unregister(); + } + await mm2.dispose(); log('Window close handler: SDK disposed successfully'); } catch (e, s) { diff --git a/lib/services/initializer/app_bootstrapper.dart b/lib/services/initializer/app_bootstrapper.dart index 11e594f5f4..a9813a080a 100644 --- a/lib/services/initializer/app_bootstrapper.dart +++ b/lib/services/initializer/app_bootstrapper.dart @@ -17,7 +17,7 @@ final class AppBootstrapper { if (_isInitialized) return; // Register core services with GetIt - _registerDependencies(kdfSdk, mm2Api); + _registerDependencies(kdfSdk, mm2Api, sparklineRepository); final timer = Stopwatch()..start(); await logger.init(); @@ -36,10 +36,15 @@ final class AppBootstrapper { } /// Register all dependencies with GetIt - void _registerDependencies(KomodoDefiSdk kdfSdk, Mm2Api mm2Api) { + void _registerDependencies( + KomodoDefiSdk kdfSdk, + Mm2Api mm2Api, + SparklineRepository sparklineRepository, + ) { // Register core services GetIt.I.registerSingleton(kdfSdk); GetIt.I.registerSingleton(mm2Api); + GetIt.I.registerSingleton(sparklineRepository); } /// A list of futures that should be completed before the app starts diff --git a/lib/shared/constants.dart b/lib/shared/constants.dart index 56dc4a9edb..a7c7235f4e 100644 --- a/lib/shared/constants.dart +++ b/lib/shared/constants.dart @@ -4,6 +4,14 @@ RegExp emailRegex = RegExp( ); const int decimalRange = 8; +const int _activationPollingIntervalMs = int.fromEnvironment( + 'ACTIVATION_POLLING_INTERVAL_MS', + defaultValue: 2000, +); +const Duration kActivationPollingInterval = Duration( + milliseconds: _activationPollingIntervalMs, +); + // stored app preferences const String storedSettingsKey = '_atomicDexStoredSettings'; const String storedAnalyticsSettingsKey = 'analytics_settings'; diff --git a/lib/views/dex/simple/confirm/taker_order_confirmation.dart b/lib/views/dex/simple/confirm/taker_order_confirmation.dart index 8ee2b829f6..fc00d471ac 100644 --- a/lib/views/dex/simple/confirm/taker_order_confirmation.dart +++ b/lib/views/dex/simple/confirm/taker_order_confirmation.dart @@ -346,6 +346,8 @@ class _TakerOrderConfirmationState extends State { final tradingEntitiesBloc = RepositoryProvider.of( context, ); + // Give MM2/KDF a short moment to register the swap before first fetch + await Future.delayed(const Duration(seconds: 1)); await tradingEntitiesBloc.fetch(); } } diff --git a/lib/views/wallet/coin_details/coin_details.dart b/lib/views/wallet/coin_details/coin_details.dart index 2a25491541..2def3288d4 100644 --- a/lib/views/wallet/coin_details/coin_details.dart +++ b/lib/views/wallet/coin_details/coin_details.dart @@ -30,7 +30,6 @@ class CoinDetails extends StatefulWidget { } class _CoinDetailsState extends State { - late TransactionHistoryBloc _txHistoryBloc; CoinPageType _selectedPageType = CoinPageType.info; String _rewardValue = ''; @@ -38,9 +37,7 @@ class _CoinDetailsState extends State { @override void initState() { - _txHistoryBloc = context.read(); WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - _txHistoryBloc.add(TransactionHistorySubscribe(coin: widget.coin)); final walletType = context.read().state.currentUser?.wallet.config.type.name ?? ''; @@ -63,21 +60,27 @@ class _CoinDetailsState extends State { @override Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - return GestureDetector( - onHorizontalDragEnd: (details) { - // Detect swipe-back gesture (swipe from left to right) - if (details.primaryVelocity != null && details.primaryVelocity! > 0) { - // Only trigger back navigation if we're on the info page - if (_selectedPageType == CoinPageType.info) { - widget.onBackButtonPressed(); + return BlocProvider( + create: (ctx) => + TransactionHistoryBloc(sdk: ctx.read()) + ..add(TransactionHistorySubscribe(coin: widget.coin)), + child: BlocBuilder( + builder: (context, state) { + return GestureDetector( + onHorizontalDragEnd: (details) { + // Detect swipe-back gesture (swipe from left to right) + if (details.primaryVelocity != null && + details.primaryVelocity! > 0) { + // Only trigger back navigation if we're on the info page + if (_selectedPageType == CoinPageType.info) { + widget.onBackButtonPressed(); + } } - } - }, - child: _buildContent(), - ); - }, + }, + child: _buildContent(), + ); + }, + ), ); } diff --git a/lib/views/wallet/coin_details/transactions/transaction_table.dart b/lib/views/wallet/coin_details/transactions/transaction_table.dart index 09b7f9ef13..9be7d35a0c 100644 --- a/lib/views/wallet/coin_details/transactions/transaction_table.dart +++ b/lib/views/wallet/coin_details/transactions/transaction_table.dart @@ -73,11 +73,18 @@ class TransactionTable extends StatelessWidget { } if (state.error != null) { + String errorText; + if (state.error!.message.contains('Asset activation failed')) { + errorText = 'Asset activation failed for ${coin.displayName}'; + } else { + errorText = LocaleKeys.connectionToServersFailing.tr( + args: [coin.displayName], + ); + } + return SliverToBoxAdapter( child: _ErrorMessage( - text: LocaleKeys.connectionToServersFailing.tr( - args: [coin.displayName], - ), + text: errorText, textColor: theme.currentGlobal.colorScheme.error, ), ); diff --git a/pubspec.lock b/pubspec.lock index e3a079fec9..334fe452fd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -372,6 +372,14 @@ packages: url: "https://pub.dev" source: hosted version: "9.1.1" + flutter_client_sse: + dependency: transitive + description: + name: flutter_client_sse + sha256: "4ce0297206473dfc064b255fe086713240002e149f52519bd48c21423e4aa5d2" + url: "https://pub.dev" + source: hosted + version: "2.0.3" flutter_driver: dependency: transitive description: flutter diff --git a/sdk b/sdk index 0b23126680..74d4999cdb 160000 --- a/sdk +++ b/sdk @@ -1 +1 @@ -Subproject commit 0b23126680e59a1aa0d5bae918a7fa89c89c2707 +Subproject commit 74d4999cdbcf06beb44eb04d069c507a349862af