diff --git a/assets/translations/en.json b/assets/translations/en.json index 3e7dda38f3..d73ae0e965 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -360,7 +360,6 @@ "backupSeedPhrase": "Backup seed phrase", "seedOr": "OR", "seedDownload": "Download seed phrase", - "seedSaveAndRemember": "Save and remember", "seedIntroWarning": "This phrase is the main access to your\nassets, save and never share this phrase", "seedSettings": "Seed phrase", "errorDescription": "Error description", @@ -460,6 +459,7 @@ "withdrawAmountTooLowError": "{} {} too low, you need > {} {} to send", "withdrawNoSuchCoinError": "Invalid selection, {} does not exist", "withdrawPreview": "Preview Withdrawal", + "withdrawPreviewZhtlcNote": "ZHTLC transactions can take a while to generate.\nPlease stay on this page until the preview is ready, otherwise you will need to start over.", "withdrawPreviewError": "Error occurred while fetching withdrawal preview", "txHistoryFetchError": "Error fetching tx history from the endpoint. Unsupported type: {}", "txHistoryNoTransactions": "Transactions are not available", @@ -510,6 +510,7 @@ "userActionRequired": "User action required", "unknown": "Unknown", "unableToActiveCoin": "Unable to activate {}", + "coinIsNotActive": "{} is not active", "feedback": "Feedback", "feedbackViewTitle": "Send us your feedback", "feedbackPageDescription": "Help us improve by sharing your suggestions, reporting bugs, or giving general feedback.", @@ -765,5 +766,29 @@ "fetchingPrivateKeysTitle": "Fetching Private Keys...", "fetchingPrivateKeysMessage": "Please wait while we securely fetch your private keys...", "pubkeyType": "Type", - "securitySettings": "Security Settings" + "securitySettings": "Security Settings", + "zhtlcConfigureTitle": "Configure {}", + "zhtlcZcashParamsPathLabel": "Zcash parameters path", + "zhtlcPathAutomaticallyDetected": "Path automatically detected", + "zhtlcSaplingParamsFolder": "Folder containing sapling params", + "zhtlcBlocksPerIterationLabel": "Blocks per iteration", + "zhtlcScanIntervalLabel": "Scan interval (ms)", + "zhtlcStartSyncFromLabel": "Start sync from:", + "zhtlcEarliestSaplingOption": "Earliest (sapling)", + "zhtlcBlockHeightOption": "Block height", + "zhtlcShieldedAddress": "Shielded", + "zhtlcDateTimeOption": "Date & Time", + "zhtlcSelectDateTimeLabel": "Select date & time", + "zhtlcZcashParamsRequired": "Zcash params path is required", + "zhtlcInvalidBlockHeight": "Enter a valid block height", + "zhtlcSelectDateTimeRequired": "Please select a date and time", + "zhtlcDownloadingZcashParams": "Downloading Zcash Parameters", + "zhtlcPreparingDownload": "Preparing download...", + "zhtlcErrorSettingUpZcash": "Error setting up Zcash parameters: {}", + "zhtlcDateSyncHint": "Selecting a date further in the past can significantly increase the activation time. \nActivation can take a little while the first time to download block cache data.\n\nTransactions and balance prior to the sync date may be missing.\nOften this can be restored by sending in and out new transactions", + "zhtlcActivating": { + "one": "Activating ZHTLC coin. Please do not close the app or tab until complete.", + "other": "Activating ZHTLC coins. Please do not close the app or tab until complete." + }, + "zhtlcActivationWarning": "This may take from a few minutes to a few hours, depending on your sync params and how long since your last sync." } \ No newline at end of file diff --git a/lib/app_config/app_config.dart b/lib/app_config/app_config.dart index 551b99947e..8378cbfba7 100644 --- a/lib/app_config/app_config.dart +++ b/lib/app_config/app_config.dart @@ -107,15 +107,8 @@ const Set excludedAssetList = { 'FENIX', 'AWR', 'BOT', - // Pirate activation params are not yet implemented, so we need to - // exclude it from the list of coins for now. - 'ARRR', - 'ZOMBIE', 'SMTF-v2', 'SFUSD', - 'VOTE2023', - 'RICK', - 'MORTY', // NFT v2 coins: https://github.com/KomodoPlatform/coins/pull/1061 will be // used in the background, so users do not need to see them. diff --git a/lib/bloc/app_bloc_root.dart b/lib/bloc/app_bloc_root.dart index 24fa08e2fb..b06509b3eb 100644 --- a/lib/bloc/app_bloc_root.dart +++ b/lib/bloc/app_bloc_root.dart @@ -171,7 +171,7 @@ class AppBlocRoot extends StatelessWidget { dexRepository: dexRepository, ), ), - RepositoryProvider(create: (_) => OrderbookBloc(api: mm2Api)), + RepositoryProvider(create: (_) => OrderbookBloc(sdk: komodoDefiSdk)), RepositoryProvider(create: (_) => myOrdersService), RepositoryProvider( create: (_) => KmdRewardsBloc(coinsRepository, mm2Api), diff --git a/lib/bloc/assets_overview/bloc/asset_overview_bloc.dart b/lib/bloc/assets_overview/bloc/asset_overview_bloc.dart index 5182b22023..48f942c1cc 100644 --- a/lib/bloc/assets_overview/bloc/asset_overview_bloc.dart +++ b/lib/bloc/assets_overview/bloc/asset_overview_bloc.dart @@ -9,6 +9,7 @@ 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/bloc/coins_bloc/asset_coin_extension.dart'; import 'package:web_dex/model/coin.dart'; part 'asset_overview_event.dart'; @@ -93,9 +94,21 @@ class AssetOverviewBloc extends Bloc { return; } - await _sdk.waitForEnabledCoinsToPassThreshold(event.coins); + final supportedCoins = await event.coins.filterSupportedCoins(); + if (supportedCoins.isEmpty) { + _log.warning('No supported coins to load portfolio overview for'); + return; + } + + await _sdk.waitForEnabledCoinsToPassThreshold(supportedCoins); + + final activeCoins = await supportedCoins.removeInactiveCoins(_sdk); + if (activeCoins.isEmpty) { + _log.warning('No active coins to load portfolio overview for'); + return; + } - final profitLossesFutures = event.coins.map((coin) async { + final profitLossesFutures = activeCoins.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. @@ -114,7 +127,7 @@ class AssetOverviewBloc extends Bloc { final profitLosses = await Future.wait(profitLossesFutures); final totalInvestment = await _investmentRepository - .calculateTotalInvestment(event.walletId, event.coins); + .calculateTotalInvestment(event.walletId, activeCoins); final profitAmount = profitLosses.fold(0.0, (sum, item) { return sum + (item.lastOrNull?.profitLoss ?? 0.0); @@ -130,7 +143,7 @@ class AssetOverviewBloc extends Bloc { emit( PortfolioAssetsOverviewLoadSuccess( - selectedAssetIds: event.coins.map((coin) => coin.id.id).toList(), + selectedAssetIds: activeCoins.map((coin) => coin.id.id).toList(), assetPortionPercentages: assetPortionPercentages, totalInvestment: totalInvestment, totalValue: FiatValue.usd(profitAmount), diff --git a/lib/bloc/bridge_form/bridge_repository.dart b/lib/bloc/bridge_form/bridge_repository.dart index 5b728a7a69..c1db2114d8 100644 --- a/lib/bloc/bridge_form/bridge_repository.dart +++ b/lib/bloc/bridge_form/bridge_repository.dart @@ -55,7 +55,6 @@ class BridgeRepository { Future getAvailableTickers() async { List coins = _coinsRepository.getKnownCoins(); coins = removeWalletOnly(coins); - coins = removeSuspended(coins, await _kdfSdk.auth.isSignedIn()); final CoinsByTicker coinsByTicker = convertToCoinsByTicker(coins); final CoinsByTicker multiProtocolCoins = diff --git a/lib/bloc/bridge_form/bridge_validator.dart b/lib/bloc/bridge_form/bridge_validator.dart index 189da19abf..0e5c59d1b1 100644 --- a/lib/bloc/bridge_form/bridge_validator.dart +++ b/lib/bloc/bridge_form/bridge_validator.dart @@ -292,7 +292,7 @@ class BridgeValidator { } DexFormError _coinNotActiveError(String abbr) { - return DexFormError(error: '$abbr is not active.'); + return DexFormError(error: LocaleKeys.coinIsNotActive.tr(args: [abbr])); } DexFormError _selectSourceProtocolError() => diff --git a/lib/bloc/cex_market_data/common/update_frequency_backoff_strategy.dart b/lib/bloc/cex_market_data/common/update_frequency_backoff_strategy.dart new file mode 100644 index 0000000000..c345e38455 --- /dev/null +++ b/lib/bloc/cex_market_data/common/update_frequency_backoff_strategy.dart @@ -0,0 +1,76 @@ +import 'dart:math' as math; + +/// A strategy for implementing exponential backoff with paired intervals. +/// The pattern is: 1min, 1min, 2min, 2min, 4min, 4min, 8min, 8min, etc. +/// This reduces API calls while still providing reasonable update frequency. +class UpdateFrequencyBackoffStrategy { + UpdateFrequencyBackoffStrategy({ + this.baseInterval = const Duration(minutes: 1), + this.maxInterval = const Duration(hours: 1), + }); + + /// The base interval for the first attempts (default: 2 minutes) + final Duration baseInterval; + + /// The maximum interval to backoff to (default: 1 hour) + final Duration maxInterval; + + int _attemptCount = 0; + + /// Reset the backoff strategy to start from the beginning + void reset() { + _attemptCount = 0; + } + + /// Get the current attempt count + int get attemptCount => _attemptCount; + + /// Get the next interval duration and increment the attempt count + Duration getNextInterval() { + final interval = getCurrentInterval(); + _attemptCount++; + return interval; + } + + /// Get the current interval duration without incrementing the attempt count + Duration getCurrentInterval() { + // Calculate which "pair" we're in (0, 1, 2, 3, ...) + // Each pair has 2 attempts with the same interval + final pairIndex = _attemptCount ~/ 2; + + // Calculate the multiplier: 2^pairIndex + final multiplier = math.pow(2, pairIndex).toInt(); + + // Calculate the interval + final intervalMs = baseInterval.inMilliseconds * multiplier; + + // Cap at maximum interval + final cappedIntervalMs = math.min(intervalMs, maxInterval.inMilliseconds); + + return Duration(milliseconds: cappedIntervalMs); + } + + /// Check if we should update the cache on the current attempt + /// Returns true for cache update attempts, false for cache-only reads + bool shouldUpdateCache() { + // Update cache on every attempt for now, but this could be modified + // to only update on certain intervals if needed + return true; + } + + /// Get a preview of the next N intervals without affecting the state + List previewNextIntervals(int count) { + final currentAttempt = _attemptCount; + final intervals = []; + + for (int i = 0; i < count; i++) { + final pairIndex = (currentAttempt + i) ~/ 2; + final multiplier = math.pow(2, pairIndex).toInt(); + final intervalMs = baseInterval.inMilliseconds * multiplier; + final cappedIntervalMs = math.min(intervalMs, maxInterval.inMilliseconds); + intervals.add(Duration(milliseconds: cappedIntervalMs)); + } + + return intervals; + } +} \ No newline at end of file diff --git a/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart index 0082d84a3f..e9f3747cf2 100644 --- a/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart +++ b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart @@ -2,18 +2,17 @@ import 'dart:async'; import 'package:bloc/bloc.dart'; import 'package:bloc_concurrency/bloc_concurrency.dart'; -import 'package:decimal/decimal.dart'; import 'package:equatable/equatable.dart'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:logging/logging.dart'; -import 'package:rational/rational.dart'; import 'package:web_dex/bloc/cex_market_data/charts.dart'; +import 'package:web_dex/bloc/cex_market_data/common/update_frequency_backoff_strategy.dart'; import 'package:web_dex/bloc/cex_market_data/portfolio_growth/portfolio_growth_repository.dart'; import 'package:web_dex/bloc/cex_market_data/sdk_auth_activation_extension.dart'; +import 'package:web_dex/bloc/coins_bloc/asset_coin_extension.dart'; import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/text_error.dart'; -import 'package:web_dex/shared/utils/extensions/legacy_coin_migration_extensions.dart'; part 'portfolio_growth_event.dart'; part 'portfolio_growth_state.dart'; @@ -23,8 +22,10 @@ class PortfolioGrowthBloc PortfolioGrowthBloc({ required PortfolioGrowthRepository portfolioGrowthRepository, required KomodoDefiSdk sdk, + UpdateFrequencyBackoffStrategy? backoffStrategy, }) : _sdk = sdk, _portfolioGrowthRepository = portfolioGrowthRepository, + _backoffStrategy = backoffStrategy ?? UpdateFrequencyBackoffStrategy(), super(const PortfolioGrowthInitial()) { // Use the restartable transformer for period change events to avoid // overlapping events if the user rapidly changes the period (i.e. faster @@ -43,6 +44,7 @@ class PortfolioGrowthBloc final PortfolioGrowthRepository _portfolioGrowthRepository; final KomodoDefiSdk _sdk; final _log = Logger('PortfolioGrowthBloc'); + final UpdateFrequencyBackoffStrategy _backoffStrategy; void _onClearPortfolioGrowth( PortfolioGrowthClearRequested event, @@ -55,8 +57,14 @@ class PortfolioGrowthBloc PortfolioGrowthPeriodChanged event, Emitter emit, ) { - final (int totalCoins, int coinsWithKnownBalance, int coinsWithKnownBalanceAndFiat) = - _calculateCoinProgressCounters(event.coins); + final coins = event.coins.withoutTestCoins(); + final ( + int totalCoins, + int coinsWithKnownBalance, + int coinsWithKnownBalanceAndFiat, + ) = _calculateCoinProgressCounters( + coins, + ); final currentState = state; if (currentState is PortfolioGrowthChartLoadSuccess) { emit( @@ -98,10 +106,9 @@ class PortfolioGrowthBloc add( PortfolioGrowthLoadRequested( - coins: event.coins, + coins: coins, selectedPeriod: event.selectedPeriod, fiatCoinId: 'USDT', - updateFrequency: event.updateFrequency, walletId: event.walletId, ), ); @@ -112,15 +119,23 @@ class PortfolioGrowthBloc Emitter emit, ) async { try { - final List coins = await _removeUnsupportedCoins(event); + final List coins = await event.coins.filterSupportedCoins( + (coin) => _portfolioGrowthRepository.isCoinChartSupported( + coin.id, + event.fiatCoinId, + ), + ); // Charts for individual coins (coin details) are parsed here as well, // and should be hidden if not supported. - if (coins.isEmpty && event.coins.length <= 1) { + final filteredEventCoins = event.coins.withoutTestCoins(); + if (coins.isEmpty && filteredEventCoins.length <= 1) { final ( int totalCoins, int coinsWithKnownBalance, int coinsWithKnownBalanceAndFiat, - ) = _calculateCoinProgressCounters(event.coins); + ) = _calculateCoinProgressCounters( + filteredEventCoins, + ); return emit( PortfolioGrowthChartUnsupported( selectedPeriod: event.selectedPeriod, @@ -132,7 +147,7 @@ class PortfolioGrowthBloc } await _loadChart( - coins, + filteredEventCoins, event, useCache: true, ).then(emit.call).catchError((Object error, StackTrace stackTrace) { @@ -145,77 +160,31 @@ class PortfolioGrowthBloc // In case most coins are activating on wallet startup, wait for at least // 50% of the coins to be enabled before attempting to load the uncached // chart. - await _sdk.waitForEnabledCoinsToPassThreshold(event.coins); + await _sdk.waitForEnabledCoinsToPassThreshold(filteredEventCoins); // Only remove inactivate/activating coins after an attempt to load the // cached chart, as the cached chart may contain inactive coins. - final activeCoins = await _removeInactiveCoins(coins); - if (activeCoins.isNotEmpty) { - await _loadChart( - activeCoins, - event, - useCache: false, - ).then(emit.call).catchError((Object error, StackTrace stackTrace) { - _log.shout('Failed to load chart', error, stackTrace); - // Don't emit an error state here. If cached and uncached attempts - // both fail, the periodic refresh attempts should recovery - // at the cost of a longer first loading time. - }); - } + await _loadChart( + filteredEventCoins, + event, + useCache: false, + ).then(emit.call).catchError((Object error, StackTrace stackTrace) { + _log.shout('Failed to load chart', error, stackTrace); + // Don't emit an error state here. If cached and uncached attempts + // both fail, the periodic refresh attempts should recovery + // at the cost of a longer first loading time. + }); } catch (error, stackTrace) { _log.shout('Failed to load portfolio growth', error, stackTrace); // Don't emit an error state here, as the periodic refresh attempts should // recover at the cost of a longer first loading time. } - final periodicUpdate = Stream.periodic( - event.updateFrequency, - ).asyncMap((_) async => _fetchPortfolioGrowthChart(event)); - - // Use await for here to allow for the async update handler. The previous - // implementation awaited the emit.forEach to ensure that cancelling the - // event handler with transformers would stop the previous periodic updates. - await for (final data in periodicUpdate) { - try { - emit( - await _handlePortfolioGrowthUpdate( - data, - event.selectedPeriod, - event.coins, - ), - ); - } catch (error, stackTrace) { - _log.shout('Failed to load portfolio growth', error, stackTrace); - final ( - int totalCoins, - int coinsWithKnownBalance, - int coinsWithKnownBalanceAndFiat, - ) = _calculateCoinProgressCounters(event.coins); - emit( - GrowthChartLoadFailure( - error: TextError(error: 'Failed to load portfolio growth'), - selectedPeriod: event.selectedPeriod, - totalCoins: totalCoins, - coinsWithKnownBalance: coinsWithKnownBalance, - coinsWithKnownBalanceAndFiat: coinsWithKnownBalanceAndFiat, - ), - ); - } - } - } + // Reset backoff strategy for new load request + _backoffStrategy.reset(); - Future> _removeUnsupportedCoins( - PortfolioGrowthLoadRequested event, - ) async { - final List coins = List.from(event.coins); - for (final coin in event.coins) { - final isCoinSupported = await _portfolioGrowthRepository - .isCoinChartSupported(coin.id, event.fiatCoinId); - if (!isCoinSupported) { - coins.remove(coin); - } - } - return coins; + // Create periodic update stream with dynamic intervals + await _runPeriodicUpdates(event, emit); } Future _loadChart( @@ -223,8 +192,9 @@ class PortfolioGrowthBloc PortfolioGrowthLoadRequested event, { required bool useCache, }) async { + final activeCoins = await coins.removeInactiveCoins(_sdk); final chart = await _portfolioGrowthRepository.getPortfolioGrowthChart( - coins, + activeCoins, fiatCoinId: event.fiatCoinId, walletId: event.walletId, useCache: useCache, @@ -234,12 +204,17 @@ class PortfolioGrowthBloc return state; } - final totalBalance = _calculateTotalBalance(coins); - final totalChange24h = await _calculateTotalChange24h(coins); - final percentageChange24h = await _calculatePercentageChange24h(coins); + final totalBalance = coins.totalLastKnownUsdBalance(_sdk); + final totalChange24h = await coins.totalChange24h(_sdk); + final percentageChange24h = await coins.percentageChange24h(_sdk); - final (int totalCoins, int coinsWithKnownBalance, int coinsWithKnownBalanceAndFiat) = - _calculateCoinProgressCounters(event.coins); + final ( + int totalCoins, + int coinsWithKnownBalance, + int coinsWithKnownBalanceAndFiat, + ) = _calculateCoinProgressCounters( + coins, + ); return PortfolioGrowthChartLoadSuccess( portfolioGrowth: chart, @@ -255,35 +230,29 @@ class PortfolioGrowthBloc ); } - Future _fetchPortfolioGrowthChart( + Future<(ChartData, List)> _fetchPortfolioGrowthChart( PortfolioGrowthLoadRequested event, ) async { // Do not let transaction loading exceptions stop the periodic updates try { - final supportedCoins = await _removeUnsupportedCoins(event); - final coins = await _removeInactiveCoins(supportedCoins); - return await _portfolioGrowthRepository.getPortfolioGrowthChart( + final supportedCoins = await event.coins.filterSupportedCoins( + (coin) => _portfolioGrowthRepository.isCoinChartSupported( + coin.id, + event.fiatCoinId, + ), + ); + final coins = await supportedCoins.removeInactiveCoins(_sdk); + final chart = await _portfolioGrowthRepository.getPortfolioGrowthChart( coins, fiatCoinId: event.fiatCoinId, walletId: event.walletId, useCache: false, ); + return (chart, coins); } catch (error, stackTrace) { _log.shout('Empty growth chart on periodic update', error, stackTrace); - return ChartData.empty(); - } - } - - 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 (ChartData.empty(), []); } - return coinsCopy; } Future _handlePortfolioGrowthUpdate( @@ -296,12 +265,17 @@ class PortfolioGrowthBloc } final percentageIncrease = growthChart.percentageIncrease; - final totalBalance = _calculateTotalBalance(coins); - final totalChange24h = await _calculateTotalChange24h(coins); - final percentageChange24h = await _calculatePercentageChange24h(coins); - - final (int totalCoins, int coinsWithKnownBalance, int coinsWithKnownBalanceAndFiat) = - _calculateCoinProgressCounters(coins); + final totalBalance = coins.totalLastKnownUsdBalance(_sdk); + final totalChange24h = await coins.totalChange24h(_sdk); + final percentageChange24h = await coins.percentageChange24h(_sdk); + + final ( + int totalCoins, + int coinsWithKnownBalance, + int coinsWithKnownBalanceAndFiat, + ) = _calculateCoinProgressCounters( + coins, + ); return PortfolioGrowthChartLoadSuccess( portfolioGrowth: growthChart, @@ -317,52 +291,6 @@ class PortfolioGrowthBloc ); } - /// Calculate the total balance of all coins in USD - double _calculateTotalBalance(List coins) { - double total = coins.fold( - 0, - (prev, coin) => prev + (coin.lastKnownUsdBalance(_sdk) ?? 0), - ); - - // Return at least 0.01 if total is positive but very small - if (total > 0 && total < 0.01) { - return 0.01; - } - - return total; - } - - /// Calculate the total 24h change in USD value - /// TODO: look into avoiding zero default values here if no data is available - Future _calculateTotalChange24h(List coins) async { - Rational totalChange = Rational.zero; - for (final coin in coins) { - final double usdBalance = coin.lastKnownUsdBalance(_sdk) ?? 0.0; - final usdBalanceDecimal = Decimal.parse(usdBalance.toString()); - final change24h = - await _sdk.marketData.priceChange24h(coin.id) ?? Decimal.zero; - totalChange += change24h * usdBalanceDecimal / Decimal.fromInt(100); - } - return totalChange; - } - - /// Calculate the percentage change over 24h for the entire portfolio - Future _calculatePercentageChange24h(List coins) async { - final double totalBalance = _calculateTotalBalance(coins); - final Rational totalBalanceRational = Rational.parse( - totalBalance.toString(), - ); - final Rational totalChange = await _calculateTotalChange24h(coins); - - // Avoid division by zero or very small balances - if (totalBalanceRational <= Rational.fromInt(1, 100)) { - return Rational.zero; - } - - // Return the percentage change - return (totalChange / totalBalanceRational) * Rational.fromInt(100); - } - /// Calculate progress counters for balances and fiat prices /// - totalCoins: total coins being considered (input list length) /// - coinsWithKnownBalance: number of coins with a known last balance @@ -383,4 +311,54 @@ class PortfolioGrowthBloc } return (totalCoins, withBalance, withBalanceAndFiat); } + + /// Run periodic updates with exponential backoff strategy + Future _runPeriodicUpdates( + PortfolioGrowthLoadRequested event, + Emitter emit, + ) async { + while (true) { + if (isClosed || emit.isDone) { + _log.fine('Stopping portfolio growth periodic updates: bloc closed.'); + break; + } + try { + await Future.delayed(_backoffStrategy.getNextInterval()); + + if (isClosed || emit.isDone) { + _log.fine( + 'Skipping portfolio growth periodic update: bloc closed during delay.', + ); + break; + } + + final (chart, coins) = await _fetchPortfolioGrowthChart(event); + emit( + await _handlePortfolioGrowthUpdate( + chart, + event.selectedPeriod, + coins, + ), + ); + } catch (error, stackTrace) { + _log.shout('Failed to load portfolio growth', error, stackTrace); + final ( + int totalCoins, + int coinsWithKnownBalance, + int coinsWithKnownBalanceAndFiat, + ) = _calculateCoinProgressCounters( + event.coins.withoutTestCoins(), + ); + emit( + GrowthChartLoadFailure( + error: TextError(error: 'Failed to load portfolio growth'), + selectedPeriod: event.selectedPeriod, + totalCoins: totalCoins, + coinsWithKnownBalance: coinsWithKnownBalance, + coinsWithKnownBalanceAndFiat: coinsWithKnownBalanceAndFiat, + ), + ); + } + } + } } diff --git a/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_event.dart b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_event.dart index 0aa750b24a..957d1e97fb 100644 --- a/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_event.dart +++ b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_event.dart @@ -17,23 +17,15 @@ class PortfolioGrowthLoadRequested extends PortfolioGrowthEvent { required this.fiatCoinId, required this.selectedPeriod, required this.walletId, - this.updateFrequency = const Duration(minutes: 1), }); final List coins; final String fiatCoinId; final Duration selectedPeriod; final String walletId; - final Duration updateFrequency; @override - List get props => [ - coins, - fiatCoinId, - selectedPeriod, - walletId, - updateFrequency, - ]; + List get props => [coins, fiatCoinId, selectedPeriod, walletId]; } class PortfolioGrowthPeriodChanged extends PortfolioGrowthEvent { @@ -41,14 +33,12 @@ class PortfolioGrowthPeriodChanged extends PortfolioGrowthEvent { required this.selectedPeriod, required this.coins, required this.walletId, - this.updateFrequency = const Duration(minutes: 1), }); final Duration selectedPeriod; final List coins; final String walletId; - final Duration updateFrequency; @override - List get props => [selectedPeriod, coins, walletId, updateFrequency]; + List get props => [selectedPeriod, coins, walletId]; } diff --git a/lib/bloc/cex_market_data/profit_loss/profit_loss_bloc.dart b/lib/bloc/cex_market_data/profit_loss/profit_loss_bloc.dart index 080e547c1b..b382058728 100644 --- a/lib/bloc/cex_market_data/profit_loss/profit_loss_bloc.dart +++ b/lib/bloc/cex_market_data/profit_loss/profit_loss_bloc.dart @@ -7,8 +7,10 @@ import 'package:equatable/equatable.dart'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:logging/logging.dart'; import 'package:web_dex/bloc/cex_market_data/charts.dart'; +import 'package:web_dex/bloc/cex_market_data/common/update_frequency_backoff_strategy.dart'; import 'package:web_dex/bloc/cex_market_data/profit_loss/profit_loss_repository.dart'; import 'package:web_dex/bloc/cex_market_data/sdk_auth_activation_extension.dart'; +import 'package:web_dex/bloc/coins_bloc/asset_coin_extension.dart'; 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'; @@ -17,8 +19,12 @@ part 'profit_loss_event.dart'; part 'profit_loss_state.dart'; class ProfitLossBloc extends Bloc { - ProfitLossBloc(this._profitLossRepository, this._sdk) - : super(const ProfitLossInitial()) { + ProfitLossBloc( + this._profitLossRepository, + this._sdk, { + UpdateFrequencyBackoffStrategy? backoffStrategy, + }) : _backoffStrategy = backoffStrategy ?? UpdateFrequencyBackoffStrategy(), + super(const ProfitLossInitial()) { // Use the restartable transformer for load events to avoid overlapping // events if the user rapidly changes the period (i.e. faster than the // previous event can complete). @@ -34,6 +40,7 @@ class ProfitLossBloc extends Bloc { final KomodoDefiSdk _sdk; final _log = Logger('ProfitLossBloc'); + final UpdateFrequencyBackoffStrategy _backoffStrategy; void _onClearPortfolioProfitLoss( ProfitLossPortfolioChartClearRequested event, @@ -47,13 +54,12 @@ class ProfitLossBloc extends Bloc { Emitter emit, ) async { try { - final supportedCoins = await _removeUnsupportedCons( - event.coins, - event.fiatCoinId, - ); + final supportedCoins = await event.coins.filterSupportedCoins(); + final filteredEventCoins = event.coins.withoutTestCoins(); + final initialActiveCoins = await supportedCoins.removeInactiveCoins(_sdk); // Charts for individual coins (coin details) are parsed here as well, // and should be hidden if not supported. - if (supportedCoins.isEmpty && event.coins.length <= 1) { + if (supportedCoins.isEmpty && filteredEventCoins.length <= 1) { return emit( PortfolioProfitLossChartUnsupported( selectedPeriod: event.selectedPeriod, @@ -63,7 +69,7 @@ class ProfitLossBloc extends Bloc { await _getProfitLossChart( event, - supportedCoins, + initialActiveCoins, useCache: true, ).then(emit.call).catchError((Object error, StackTrace stackTrace) { const errorMessage = 'Failed to load CACHED portfolio profit/loss'; @@ -73,8 +79,10 @@ class ProfitLossBloc extends Bloc { }); // Fetch the un-cached version of the chart to update the cache. - await _sdk.waitForEnabledCoinsToPassThreshold(supportedCoins); - final activeCoins = await _removeInactiveCoins(supportedCoins); + if (supportedCoins.isNotEmpty) { + await _sdk.waitForEnabledCoinsToPassThreshold(supportedCoins); + } + final activeCoins = await supportedCoins.removeInactiveCoins(_sdk); if (activeCoins.isNotEmpty) { await _getProfitLossChart( event, @@ -94,19 +102,11 @@ class ProfitLossBloc extends Bloc { // recover at the cost of a longer first loading time. } - await emit.forEach( - Stream.periodic(event.updateFrequency).asyncMap( - (_) async => _getProfitLossChart(event, event.coins, useCache: false), - ), - onData: (ProfitLossState updatedChartState) => updatedChartState, - onError: (e, s) { - _log.shout('Failed to load portfolio profit/loss', e, s); - return ProfitLossLoadFailure( - error: TextError(error: 'Failed to load portfolio profit/loss'), - selectedPeriod: event.selectedPeriod, - ); - }, - ); + // Reset backoff strategy for new load request + _backoffStrategy.reset(); + + // Create periodic update stream with dynamic intervals + await _runPeriodicUpdates(event, emit); } Future _getProfitLossChart( @@ -119,6 +119,7 @@ class ProfitLossBloc extends Bloc { try { final filteredChart = await _getSortedProfitLossChartForCoins( event, + coins, useCache: useCache, ); final unCachedProfitIncrease = filteredChart.increase; @@ -139,19 +140,6 @@ class ProfitLossBloc extends Bloc { } } - Future> _removeUnsupportedCons( - List walletCoins, - String fiatCoinId, - ) async { - final coins = List.of(walletCoins); - for (final coin in coins) { - if (coin.isTestCoin) { - coins.remove(coin); - } - } - return coins; - } - Future _onPortfolioPeriodChanged( ProfitLossPortfolioPeriodChanged event, Emitter emit, @@ -189,7 +177,8 @@ class ProfitLossBloc extends Bloc { } Future _getSortedProfitLossChartForCoins( - ProfitLossPortfolioChartLoadRequested event, { + ProfitLossPortfolioChartLoadRequested event, + List coins, { bool useCache = true, }) async { if (!await _sdk.auth.isSignedIn()) { @@ -197,8 +186,18 @@ class ProfitLossBloc extends Bloc { return ChartData.empty(); } + final supportedCoins = await coins.filterSupportedCoins(); + if (supportedCoins.isEmpty) { + _log.warning('No supported coins to load profit/loss chart for'); + return ChartData.empty(); + } + final activeCoins = await supportedCoins.removeInactiveCoins(_sdk); + if (activeCoins.isEmpty) { + _log.warning('No active coins to load profit/loss chart for'); + return ChartData.empty(); + } final chartsList = await Future.wait( - event.coins.map((coin) async { + activeCoins.map((coin) async { // Catch any errors and return an empty chart to prevent a single coin // from breaking the entire portfolio chart. try { @@ -233,15 +232,43 @@ class ProfitLossBloc extends Bloc { 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); + /// Run periodic updates with exponential backoff strategy + Future _runPeriodicUpdates( + ProfitLossPortfolioChartLoadRequested event, + Emitter emit, + ) async { + while (true) { + if (isClosed || emit.isDone) { + _log.fine('Stopping profit/loss periodic updates: bloc closed.'); + break; + } + try { + await Future.delayed(_backoffStrategy.getNextInterval()); + + if (isClosed || emit.isDone) { + _log.fine( + 'Skipping profit/loss periodic update: bloc closed during delay.', + ); + break; + } + + final supportedCoins = await event.coins.filterSupportedCoins(); + final activeCoins = await supportedCoins.removeInactiveCoins(_sdk); + final updatedChartState = await _getProfitLossChart( + event, + activeCoins, + useCache: false, + ); + emit(updatedChartState); + } catch (error, stackTrace) { + _log.shout('Failed to load portfolio profit/loss', error, stackTrace); + emit( + ProfitLossLoadFailure( + error: TextError(error: 'Failed to load portfolio profit/loss'), + selectedPeriod: event.selectedPeriod, + ), + ); } } - return coinsCopy; } } diff --git a/lib/bloc/cex_market_data/profit_loss/profit_loss_event.dart b/lib/bloc/cex_market_data/profit_loss/profit_loss_event.dart index 14ff2ff6da..f6ff6591d0 100644 --- a/lib/bloc/cex_market_data/profit_loss/profit_loss_event.dart +++ b/lib/bloc/cex_market_data/profit_loss/profit_loss_event.dart @@ -17,13 +17,11 @@ class ProfitLossPortfolioChartLoadRequested extends ProfitLossEvent { required this.fiatCoinId, required this.selectedPeriod, required this.walletId, - this.updateFrequency = const Duration(minutes: 1), }); final List coins; final String fiatCoinId; final Duration selectedPeriod; - final Duration updateFrequency; final String walletId; @override @@ -32,19 +30,16 @@ class ProfitLossPortfolioChartLoadRequested extends ProfitLossEvent { fiatCoinId, selectedPeriod, walletId, - updateFrequency, ]; } class ProfitLossPortfolioPeriodChanged extends ProfitLossEvent { const ProfitLossPortfolioPeriodChanged({ required this.selectedPeriod, - this.updateFrequency = const Duration(minutes: 1), }); final Duration selectedPeriod; - final Duration updateFrequency; @override - List get props => [selectedPeriod, updateFrequency]; + List get props => [selectedPeriod]; } diff --git a/lib/bloc/coins_bloc/asset_coin_extension.dart b/lib/bloc/coins_bloc/asset_coin_extension.dart index 58cc78fb75..f1eef13e7c 100644 --- a/lib/bloc/coins_bloc/asset_coin_extension.dart +++ b/lib/bloc/coins_bloc/asset_coin_extension.dart @@ -2,9 +2,12 @@ import 'package:decimal/decimal.dart'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:rational/rational.dart' show Rational; import 'package:web_dex/app_config/app_config.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/coin_type.dart'; +import 'package:web_dex/shared/utils/extensions/collection_extensions.dart'; +import 'package:web_dex/shared/utils/extensions/legacy_coin_migration_extensions.dart'; extension AssetCoinExtension on Asset { Coin toCoin() { @@ -15,7 +18,7 @@ extension AssetCoinExtension on Asset { final logoImageUrl = config.valueOrNull('logo_image_url'); final isCustomToken = (config.valueOrNull('is_custom_token') ?? false) || - logoImageUrl != null; + logoImageUrl != null; final ProtocolData protocolData = ProtocolData( platform: id.parentId?.id ?? platform ?? '', @@ -38,8 +41,9 @@ extension AssetCoinExtension on Asset { isTestCoin: protocol.isTestnet, coingeckoId: id.symbol.coinGeckoId, swapContractAddress: config.valueOrNull('swap_contract_address'), - fallbackSwapContract: - config.valueOrNull('fallback_swap_contract'), + fallbackSwapContract: config.valueOrNull( + 'fallback_swap_contract', + ), priority: priorityCoinsAbbrMap[id.id] ?? 0, state: CoinState.inactive, walletOnly: config.valueOrNull('wallet_only') ?? false, @@ -49,8 +53,11 @@ extension AssetCoinExtension on Asset { ); } - String? get contractAddress => protocol.config - .valueOrNull('protocol', 'protocol_data', 'contract_address'); + String? get contractAddress => protocol.config.valueOrNull( + 'protocol', + 'protocol_data', + 'contract_address', + ); String? get platform => protocol.config.valueOrNull('protocol', 'protocol_data', 'platform'); } @@ -96,6 +103,8 @@ extension CoinTypeExtension on CoinSubClass { return CoinType.erc20; case CoinSubClass.krc20: return CoinType.krc20; + case CoinSubClass.zhtlc: + return CoinType.zhtlc; default: return CoinType.utxo; } @@ -166,6 +175,8 @@ extension CoinSubClassExtension on CoinType { return CoinSubClass.erc20; case CoinType.krc20: return CoinSubClass.krc20; + case CoinType.zhtlc: + return CoinSubClass.zhtlc; } } } @@ -201,10 +212,7 @@ extension AssetBalanceExtension on Coin { KomodoDefiSdk sdk, { bool activateIfNeeded = true, }) { - return sdk.balances.watchBalance( - id, - activateIfNeeded: activateIfNeeded, - ); + return sdk.balances.watchBalance(id, activateIfNeeded: activateIfNeeded); } /// Get the last-known balance for this coin. @@ -227,3 +235,109 @@ extension AssetBalanceExtension on Coin { return (balance * price).spendable.toDouble(); } } + +extension AssetListOps on List { + Future> removeInactiveAssets(KomodoDefiSdk sdk) async { + final activeAssets = await sdk.assets.getActivatedAssets(); + final activeAssetsMap = activeAssets.map((e) => e.id).toSet(); + + return where( + (asset) => activeAssetsMap.contains(asset.id), + ).unmodifiable().toList(); + } + + Future> removeActiveAssets(KomodoDefiSdk sdk) async { + final activeAssets = await sdk.assets.getActivatedAssets(); + final activeAssetsMap = activeAssets.map((e) => e.id).toSet(); + + return where( + (asset) => !activeAssetsMap.contains(asset.id), + ).unmodifiable().toList(); + } +} + +extension CoinSupportOps on Iterable { + /// Returns a list excluding test coins. Useful when filtering coins before + /// running portfolio calculations that assume production assets only. + List withoutTestCoins() => + where((coin) => !coin.isTestCoin).unmodifiable().toList(); + + /// Filters out unsupported coins by first removing test coins and then + /// evaluating the optional [isSupported] predicate. When the predicate is not + /// provided, only test coins are removed. + Future> filterSupportedCoins([ + Future Function(Coin coin)? isSupported, + ]) async { + final predicate = isSupported ?? _alwaysSupported; + final supportedCoins = []; + for (final coin in this) { + if (coin.isTestCoin) continue; + if (await predicate(coin)) { + supportedCoins.add(coin); + } + } + return supportedCoins.unmodifiable().toList(); + } + + static Future _alwaysSupported(Coin _) async => true; + + Future> removeInactiveCoins(KomodoDefiSdk sdk) async { + final activeCoins = await sdk.assets.getActivatedAssets(); + final activeCoinsMap = activeCoins.map((e) => e.id).toSet(); + + return where( + (coin) => activeCoinsMap.contains(coin.id), + ).unmodifiable().toList(); + } + + Future> removeActiveCoins(KomodoDefiSdk sdk) async { + final activeCoins = await sdk.assets.getActivatedAssets(); + final activeCoinsMap = activeCoins.map((e) => e.id).toSet(); + + return where( + (coin) => !activeCoinsMap.contains(coin.id), + ).unmodifiable().toList(); + } + + double totalLastKnownUsdBalance(KomodoDefiSdk sdk) { + double total = fold( + 0.00, + (prev, coin) => prev + (coin.lastKnownUsdBalance(sdk) ?? 0), + ); + + // Return at least 0.01 if total is positive but very small + if (total > 0 && total < 0.01) { + return 0.01; + } + + return total; + } + + Future totalChange24h(KomodoDefiSdk sdk) async { + Rational totalChange = Rational.zero; + for (final coin in this) { + final double usdBalance = coin.lastKnownUsdBalance(sdk) ?? 0.0; + final usdBalanceDecimal = Decimal.parse(usdBalance.toString()); + final change24h = + await sdk.marketData.priceChange24h(coin.id) ?? Decimal.zero; + totalChange += change24h * usdBalanceDecimal / Decimal.fromInt(100); + } + return totalChange; + } + + Future percentageChange24h(KomodoDefiSdk sdk) async { + final double totalBalance = totalLastKnownUsdBalance(sdk); + final Rational totalBalanceRational = Rational.parse( + totalBalance.toString(), + ); + final Rational totalChange = await totalChange24h(sdk); + + // Avoid division by zero or very small balances + if (totalBalanceRational <= Rational.fromInt(1, 100)) { + return Rational.zero; + } + + // Return the percentage change + return (totalChange / totalBalanceRational) * Rational.fromInt(100); + } +} diff --git a/lib/bloc/coins_bloc/coins_bloc.dart b/lib/bloc/coins_bloc/coins_bloc.dart index 59cf2c6acf..928121ea39 100644 --- a/lib/bloc/coins_bloc/coins_bloc.dart +++ b/lib/bloc/coins_bloc/coins_bloc.dart @@ -8,11 +8,11 @@ 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'; 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'; @@ -49,6 +49,7 @@ class CoinsBloc extends Bloc { StreamSubscription? _enabledCoinsSubscription; Timer? _updateBalancesTimer; Timer? _updatePricesTimer; + bool _isInitialActivationInProgress = false; @override Future close() async { @@ -64,11 +65,24 @@ class CoinsBloc extends Bloc { Emitter emit, ) async { try { - // Return early if the coin is not yet in wallet coins, meaning that - // it's not yet activated. - // TODO: update this once coin activation is fully handled by the SDK + if (_isInitialActivationInProgress) { + _log.info( + 'Skipping pubkeys request for ${event.coinId} while initial activation is in progress.', + ); + return; + } + + // Coins are added to walletCoins before activation even starts + // to show them in the UI regardless of activation state. + // If the coin is not found here, it means the auth state handler + // has not pre-populated the list with activating coins yet. final coin = state.walletCoins[event.coinId]; - if (coin == null) return; + if (coin == null) { + _log.warning( + 'Coin ${event.coinId} not found in wallet coins, cannot fetch pubkeys', + ); + return; + } // Get pubkeys from the SDK through the repo final asset = _kdfSdk.assets.available[coin.id]!; @@ -142,17 +156,6 @@ class CoinsBloc extends Bloc { ); }, ); - - final coinUpdates = _syncIguanaCoinsStates(); - await emit.forEach( - coinUpdates, - onData: (coin) => - state.copyWith(walletCoins: {...state.walletCoins, coin.id.id: coin}), - onError: (error, stackTrace) { - _log.severe('Error syncing iguana coins states', error, stackTrace); - return state; - }, - ); } Future _onWalletCoinUpdated( @@ -206,18 +209,6 @@ class CoinsBloc extends Bloc { emit(_prePopulateListWithActivatingCoins(event.coinIds)); await _activateCoins(event.coinIds, emit); - final currentWallet = await _kdfSdk.currentWallet(); - if (currentWallet?.config.type == WalletType.iguana || - currentWallet?.config.type == WalletType.hdwallet) { - final coinUpdates = _syncIguanaCoinsStates(); - await emit.forEach( - coinUpdates, - onData: (coin) => state.copyWith( - walletCoins: {...state.walletCoins, coin.id.id: coin}, - ), - ); - } - add(CoinsBalancesRefreshed()); } @@ -326,6 +317,7 @@ class CoinsBloc extends Bloc { CoinsSessionStarted event, Emitter emit, ) async { + _isInitialActivationInProgress = true; try { // Ensure any cached addresses/pubkeys from a previous wallet are cleared // so that UI fetches fresh pubkeys for the newly logged-in wallet. @@ -335,21 +327,29 @@ class CoinsBloc extends Bloc { // 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 - final allCoinsToActivate = currentWallet.config.activatedCoins; + final coinsToActivate = currentWallet.config.activatedCoins; // Filter out blocked coins before activation - final allowedCoins = allCoinsToActivate.where((coinId) { + final allowedCoins = coinsToActivate.where((coinId) { final assets = _kdfSdk.assets.findAssetsByConfigId(coinId); if (assets.isEmpty) return false; return !_tradingStatusService.isAssetBlocked(assets.single.id); }); emit(_prePopulateListWithActivatingCoins(allowedCoins)); - await _activateCoins(allowedCoins, emit); - - add(CoinsBalancesRefreshed()); - add(CoinsBalanceMonitoringStarted()); + _scheduleInitialBalanceRefresh(allowedCoins); + final activationFuture = _activateCoins(allowedCoins, emit); + unawaited(() async { + try { + await activationFuture; + } catch (e, s) { + _log.shout('Error during initial coin activation', e, s); + } finally { + _isInitialActivationInProgress = false; + } + }()); } catch (e, s) { + _isInitialActivationInProgress = false; _log.shout('Error on login', e, s); } } @@ -358,6 +358,7 @@ class CoinsBloc extends Bloc { CoinsSessionEnded event, Emitter emit, ) async { + _resetInitialActivationState(); add(CoinsBalanceMonitoringStopped()); emit( @@ -371,6 +372,60 @@ class CoinsBloc extends Bloc { _coinsRepo.flushCache(); } + void _scheduleInitialBalanceRefresh(Iterable coinsToActivate) { + if (isClosed) return; + + final knownCoins = _coinsRepo.getKnownCoinsMap(); + final walletCoinsForThreshold = coinsToActivate + .map((coinId) => knownCoins[coinId]) + .whereType() + .toList(); + + if (walletCoinsForThreshold.isEmpty) { + add(CoinsBalancesRefreshed()); + add(CoinsBalanceMonitoringStarted()); + return; + } + + unawaited(() async { + var triggeredByThreshold = false; + try { + triggeredByThreshold = await _kdfSdk.waitForEnabledCoinsToPassThreshold( + walletCoinsForThreshold, + threshold: 0.8, + timeout: const Duration(minutes: 1), + ); + } catch (e, s) { + _log.shout( + 'Failed while waiting for enabled coins threshold during login', + e, + s, + ); + } + + if (isClosed) { + return; + } + + if (triggeredByThreshold) { + _log.fine( + 'Initial balance refresh triggered after 80% of coins activated.', + ); + } else { + _log.fine( + 'Initial balance refresh triggered after timeout while waiting for coin activation.', + ); + } + + add(CoinsBalancesRefreshed()); + add(CoinsBalanceMonitoringStarted()); + }()); + } + + void _resetInitialActivationState() { + _isInitialActivationInProgress = false; + } + Future _activateCoins( Iterable coins, Emitter emit, @@ -412,7 +467,10 @@ class CoinsBloc extends Bloc { return sdkCoin?.copyWith(state: CoinState.activating); }) .where((coin) => coin != null) - .cast(), + .cast() + // Do not pre-populate zhtlc coins, as they require configuration + // and longer activation times, and are handled separately. + .where((coin) => coin.id.subClass != CoinSubClass.zhtlc), key: (element) => (element as Coin).id.id, ); return state.copyWith( @@ -420,78 +478,4 @@ class CoinsBloc extends Bloc { coins: {...knownCoins, ...state.coins, ...activatingCoins}, ); } - - /// Yields one coin at a time to provide visual feedback to the user as - /// coins are activated. - /// - /// When multiple coins are found for the provided IDs, - Stream _syncIguanaCoinsStates() async* { - final coinsBlocWalletCoinsState = state.walletCoins; - final previouslyActivatedCoinIds = - (await _kdfSdk.currentWallet())?.config.activatedCoins ?? []; - - final walletAssets = []; - for (final coinId in previouslyActivatedCoinIds) { - final assets = _kdfSdk.assets.findAssetsByConfigId(coinId); - if (assets.isEmpty) { - _log.warning( - 'No assets found for activated coin ID: $coinId. ' - 'This coin will be skipped during synchronization.', - ); - continue; - } - if (assets.length > 1) { - final assetIds = assets.map((a) => a.id.id).join(', '); - _log.shout( - 'Multiple assets found for activated coin ID: $coinId. ' - 'Expected single asset, found ${assets.length}: $assetIds. ', - ); - } - - // This is expected to throw if there are multiple assets, to stick - // to the strategy of using `.single` elsewhere in the codebase. - walletAssets.add(assets.single); - } - - final coinsToSync = _getWalletCoinsNotInState( - walletAssets, - coinsBlocWalletCoinsState, - ); - if (coinsToSync.isNotEmpty) { - _log.info( - 'Found ${coinsToSync.length} wallet coins not in state, ' - 'syncing them to state as suspended', - ); - yield* Stream.fromIterable(coinsToSync); - } - } - - List _getWalletCoinsNotInState( - List walletAssets, - Map coinsBlocWalletCoinsState, - ) { - final List coinsToSyncToState = []; - - final enabledAssetsNotInState = walletAssets - .where((asset) => !coinsBlocWalletCoinsState.containsKey(asset.id.id)) - .toList(); - - // Show assets that are in the wallet metadata but not in the state. This might - // happen if activation occurs outside of the coins bloc, like the dex or - // coins manager auto-activation or deactivation. - for (final asset in enabledAssetsNotInState) { - final coin = _coinsRepo.getCoinFromId(asset.id); - if (coin == null) { - _log.shout( - 'Coin ${asset.id.id} not found in coins repository, ' - 'skipping sync from wallet metadata to coins bloc state.', - ); - continue; - } - - coinsToSyncToState.add(coin.copyWith(state: CoinState.suspended)); - } - - return coinsToSyncToState; - } } diff --git a/lib/bloc/coins_bloc/coins_repo.dart b/lib/bloc/coins_bloc/coins_repo.dart index 101c76079a..d43411083a 100644 --- a/lib/bloc/coins_bloc/coins_repo.dart +++ b/lib/bloc/coins_bloc/coins_repo.dart @@ -4,6 +4,7 @@ import 'dart:math' show min; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart' show NetworkImage; + import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart' as kdf_rpc; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; @@ -27,17 +28,19 @@ import 'package:web_dex/model/cex_price.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/kdf_auth_metadata_extension.dart'; import 'package:web_dex/model/text_error.dart'; -import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/model/withdraw_details/withdraw_details.dart'; +import 'package:web_dex/services/arrr_activation/arrr_activation_service.dart'; class CoinsRepo { CoinsRepo({ required KomodoDefiSdk kdfSdk, required MM2 mm2, required TradingStatusService tradingStatusService, + required ArrrActivationService arrrActivationService, }) : _kdfSdk = kdfSdk, _mm2 = mm2, - _tradingStatusService = tradingStatusService { + _tradingStatusService = tradingStatusService, + _arrrActivationService = arrrActivationService { enabledAssetsChanges = StreamController.broadcast( onListen: () => _enabledAssetListenerCount += 1, onCancel: () => _enabledAssetListenerCount -= 1, @@ -47,6 +50,7 @@ class CoinsRepo { final KomodoDefiSdk _kdfSdk; final MM2 _mm2; final TradingStatusService _tradingStatusService; + final ArrrActivationService _arrrActivationService; final _log = Logger('CoinsRepo'); @@ -89,7 +93,7 @@ class CoinsRepo { BalanceInfo? lastKnownBalance(AssetId id) => _kdfSdk.balances.lastKnown(id); /// Subscribe to balance updates for an asset using the SDK's balance manager - void _subscribeToBalanceUpdates(Asset asset, Coin coin) { + void _subscribeToBalanceUpdates(Asset asset) { // Cancel any existing subscription for this asset _balanceWatchers[asset.id]?.cancel(); @@ -194,29 +198,7 @@ class CoinsRepo { 'Wallet [KdfUser].wallet extension instead.', ) Future> getWalletCoins() async { - final currentUser = await _kdfSdk.auth.currentUser; - if (currentUser == null) { - return []; - } - - final walletAssets = currentUser.wallet.config.activatedCoins - .map((coinId) { - final assets = _kdfSdk.assets.findAssetsByConfigId(coinId); - if (assets.isEmpty) { - _log.warning('No assets found for coinId: $coinId'); - return null; - } - if (assets.length > 1) { - _log.shout( - 'Multiple assets found for coinId: $coinId (${assets.length} assets). ' - 'Selecting the first asset: ${assets.first.id.id}', - ); - } - return assets.single; - }) - .whereType() - .toList(); - + final walletAssets = await _kdfSdk.getWalletAssets(); return _tradingStatusService .filterAllowedAssets(walletAssets) .map(_assetToCoinWithoutAddress) @@ -268,14 +250,14 @@ class CoinsRepo { /// exponential backoff for up to the specified duration. /// /// **Retry Configuration:** - /// - Default: 500ms → 1s → 2s → 4s → 8s → 10s → 10s... (30 attempts ≈ 3 minutes) + /// - Default: 500ms → 1s → 2s → 4s → 8s → 10s → 10s... (15 attempts ≈ 105 seconds) /// - Configurable via [maxRetryAttempts], [initialRetryDelay], and [maxRetryDelay] /// /// **Parameters:** /// - [assets]: List of assets to activate /// - [notifyListeners]: Whether to broadcast state changes to listeners (default: true) /// - [addToWalletMetadata]: Whether to add assets to wallet metadata (default: true) - /// - [maxRetryAttempts]: Maximum number of retry attempts (default: 30) + /// - [maxRetryAttempts]: Maximum number of retry attempts (default: 15) /// - [initialRetryDelay]: Initial delay between retries (default: 500ms) /// - [maxRetryDelay]: Maximum delay between retries (default: 10s) /// @@ -292,7 +274,7 @@ class CoinsRepo { List assets, { bool notifyListeners = true, bool addToWalletMetadata = true, - int maxRetryAttempts = 30, + int maxRetryAttempts = 15, Duration initialRetryDelay = const Duration(milliseconds: 500), Duration maxRetryDelay = const Duration(seconds: 10), }) async { @@ -303,6 +285,30 @@ class CoinsRepo { return; } + // Separate ZHTLC and regular assets + final zhtlcAssets = assets + .where((asset) => asset.id.subClass == CoinSubClass.zhtlc) + .toList(); + final regularAssets = assets + .where((asset) => asset.id.subClass != CoinSubClass.zhtlc) + .toList(); + + // Process ZHTLC assets separately + if (zhtlcAssets.isNotEmpty) { + await _activateZhtlcAssets( + zhtlcAssets, + zhtlcAssets.map((asset) => _assetToCoinWithoutAddress(asset)).toList(), + notifyListeners: notifyListeners, + addToWalletMetadata: addToWalletMetadata, + ); + } + + // Continue with regular asset processing for non-ZHTLC assets + if (regularAssets.isEmpty) return; + + // Update assets list to only include regular assets for remaining processing + assets = regularAssets; + if (addToWalletMetadata) { // Ensure the wallet metadata is updated with the assets before activation // This is to ensure that the wallet metadata is always in sync with the assets @@ -356,13 +362,13 @@ class CoinsRepo { _broadcastAsset(parentCoin.copyWith(state: CoinState.active)); } } - _subscribeToBalanceUpdates(asset, coin); + _subscribeToBalanceUpdates(asset); if (coin.id.parentId != null) { final parentAsset = _kdfSdk.assets.available[coin.id.parentId]; if (parentAsset == null) { _log.warning('Parent asset not found: ${coin.id.parentId}'); } else { - _subscribeToBalanceUpdates(parentAsset, coin); + _subscribeToBalanceUpdates(parentAsset); } } } catch (e, s) { @@ -414,14 +420,14 @@ class CoinsRepo { /// activated coins and retry failed activations with exponential backoff. /// /// **Retry Configuration:** - /// - Default: 500ms → 1s → 2s → 4s → 8s → 10s → 10s... (30 attempts ≈ 3 minutes) + /// - Default: 500ms → 1s → 2s → 4s → 8s → 10s → 10s... (15 attempts ≈ 105 seconds) /// - Configurable via [maxRetryAttempts], [initialRetryDelay], and [maxRetryDelay] /// /// **Parameters:** /// - [coins]: List of coins to activate /// - [notify]: Whether to broadcast state changes to listeners (default: true) /// - [addToWalletMetadata]: Whether to add assets to wallet metadata (default: true) - /// - [maxRetryAttempts]: Maximum number of retry attempts (default: 30) + /// - [maxRetryAttempts]: Maximum number of retry attempts (default: 15) /// - [initialRetryDelay]: Initial delay between retries (default: 500ms) /// - [maxRetryDelay]: Maximum delay between retries (default: 10s) /// @@ -441,7 +447,7 @@ class CoinsRepo { List coins, { bool notify = true, bool addToWalletMetadata = true, - int maxRetryAttempts = 30, + int maxRetryAttempts = 15, Duration initialRetryDelay = const Duration(milliseconds: 500), Duration maxRetryDelay = const Duration(seconds: 10), }) async { @@ -542,7 +548,23 @@ class CoinsRepo { 'select from the available options.', ) Future getFirstPubkey(String coinId) async { - final asset = _kdfSdk.assets.findAssetsByConfigId(coinId).single; + final assets = _kdfSdk.assets.findAssetsByConfigId(coinId); + if (assets.isEmpty) { + _log.warning( + 'Unable to fetch pubkey for coinId $coinId because the asset is no longer available.', + ); + return null; + } + + if (assets.length > 1) { + final assetIds = assets.map((asset) => asset.id.id).join(', '); + final message = + 'Multiple assets found for coinId $coinId while fetching pubkey: $assetIds'; + _log.shout(message); + throw StateError(message); + } + + final asset = assets.single; final pubkeys = await _kdfSdk.pubkeys.getPubkeys(asset); if (pubkeys.keys.isEmpty) { return null; @@ -575,12 +597,7 @@ class CoinsRepo { // will hit rate limits and have reduced market metrics functionality. // This will happen regardless of chunk size. The rate limits are per IP // per hour. - final coinIds = await _kdfSdk.getWalletCoinIds(); - final activatedAssets = coinIds - .map((coinId) => _kdfSdk.assets.findAssetsByConfigId(coinId)) - .where((assets) => assets.isNotEmpty) - .map((assets) => assets.single) - .toList(); + final activatedAssets = await _kdfSdk.getWalletAssets(); final Iterable targetAssets = activatedAssets.isNotEmpty ? activatedAssets : _kdfSdk.assets.available.values; @@ -732,4 +749,121 @@ class CoinsRepo { return BlocResponse(result: withdrawDetails); } + + Future _activateZhtlcAssets( + List assets, + List coins, { + bool notifyListeners = true, + bool addToWalletMetadata = true, + }) async { + final inactiveAssets = await assets.removeActiveAssets(_kdfSdk); + for (final asset in inactiveAssets) { + final coin = coins.firstWhere((coin) => coin.id == asset.id); + await _activateZhtlcAsset( + asset, + coin, + notifyListeners: notifyListeners, + addToWalletMetadata: addToWalletMetadata, + ); + } + } + + /// Activates a ZHTLC asset using ArrrActivationService + /// This will wait for user configuration if needed before proceeding with activation + /// Mirrors the notify and addToWalletMetadata functionality of activateAssetsSync + Future _activateZhtlcAsset( + Asset asset, + Coin coin, { + bool notifyListeners = true, + bool addToWalletMetadata = true, + }) async { + try { + _log.info('Starting ZHTLC activation for ${asset.id.id}'); + + // Use the service's future-based activation which will handle configuration + // The service will emit to its stream for UI to handle, and this future will + // complete only after configuration is provided and activation succeeds. + // This ensures CoinsRepo waits for user inputs for config params from the dialog + // before proceeding with activation, and doesn't broadcast activation status + // until config parameters are received and (desktop) params files downloaded. + final result = await _arrrActivationService.activateArrr(asset); + result.when( + success: (progress) async { + _log.info('ZHTLC asset activated successfully: ${asset.id.id}'); + + // Add assets after activation regardless of success or failure + if (addToWalletMetadata) { + await _addAssetsToWalletMetdata([asset.id]); + } + + if (notifyListeners) { + _broadcastAsset(coin.copyWith(state: CoinState.activating)); + } + + if (notifyListeners) { + _broadcastAsset(coin.copyWith(state: CoinState.active)); + if (coin.id.parentId != null) { + final parentCoin = _assetToCoinWithoutAddress( + _kdfSdk.assets.available[coin.id.parentId]!, + ); + _broadcastAsset(parentCoin.copyWith(state: CoinState.active)); + } + } + + _subscribeToBalanceUpdates(asset); + if (coin.id.parentId != null) { + final parentAsset = _kdfSdk.assets.available[coin.id.parentId]; + if (parentAsset == null) { + _log.warning('Parent asset not found: ${coin.id.parentId}'); + } else { + _subscribeToBalanceUpdates(parentAsset); + } + } + + if (coin.logoImageUrl?.isNotEmpty ?? false) { + AssetIcon.registerCustomIcon( + coin.id, + NetworkImage(coin.logoImageUrl!), + ); + } + }, + error: (message) { + _log.severe( + 'ZHTLC asset activation failed: ${asset.id.id} - $message', + ); + + if (notifyListeners) { + _broadcastAsset(coin.copyWith(state: CoinState.suspended)); + } + + throw Exception('ZHTLC activation failed: $message'); + }, + needsConfiguration: (coinId, requiredSettings) { + _log.severe( + 'ZHTLC activation should not return needsConfiguration in future-based call', + ); + _log.severe( + 'Unexpected needsConfiguration result for ${asset.id.id}', + ); + + if (notifyListeners) { + _broadcastAsset(coin.copyWith(state: CoinState.suspended)); + } + + throw Exception( + 'ZHTLC activation configuration not handled properly', + ); + }, + ); + } catch (e, s) { + _log.severe('Error activating ZHTLC asset ${asset.id.id}', e, s); + + // Broadcast suspended state if requested + if (notifyListeners) { + _broadcastAsset(coin.copyWith(state: CoinState.suspended)); + } + + rethrow; + } + } } diff --git a/lib/bloc/coins_manager/coins_manager_bloc.dart b/lib/bloc/coins_manager/coins_manager_bloc.dart index bcc04ec220..9e0d9741fc 100644 --- a/lib/bloc/coins_manager/coins_manager_bloc.dart +++ b/lib/bloc/coins_manager/coins_manager_bloc.dart @@ -57,6 +57,11 @@ class CoinsManagerBloc extends Bloc { final TradingEntitiesBloc _tradingEntitiesBloc; final _log = Logger('CoinsManagerBloc'); + // Cache for expensive operations + Map? _cachedKnownCoinsMap; + List? _cachedWalletCoins; + bool? _cachedTestCoinsEnabled; + Future _onCoinsUpdate( CoinsManagerCoinsUpdate event, Emitter emit, @@ -64,7 +69,12 @@ class CoinsManagerBloc extends Bloc { final List filters = []; final mergedCoinsList = _mergeCoinLists( - await _getOriginalCoinList(_coinsRepo, event.action), + await _getOriginalCoinList( + _coinsRepo, + event.action, + cachedKnownCoinsMap: _cachedKnownCoinsMap, + cachedWalletCoins: _cachedWalletCoins, + ), state.coins, ).toList(); @@ -110,19 +120,38 @@ class CoinsManagerBloc extends Bloc { CoinsManagerCoinsListReset event, Emitter emit, ) async { + _cachedWalletCoins = null; + _cachedTestCoinsEnabled = null; + emit( state.copyWith( action: event.action, - coins: [], + coins: _cachedKnownCoinsMap?.values.toList() ?? [], selectedCoins: const [], searchPhrase: '', selectedCoinTypes: const [], isSwitching: false, ), ); + + // Cache expensive operations when opening the list, as these values + // should not change while the list is open. + // Known coins map can be cached for longer, but would need to add an + // auth listener to clear it on logout/login, so leaving as-is for now. + // Wallet and test coins can be changed by the user outside of this + // bloc within the same auth session, so they must always be cleared. + _cachedKnownCoinsMap = _coinsRepo.getKnownCoinsMap( + excludeExcludedAssets: true, + ); + _cachedWalletCoins = await _coinsRepo.getWalletCoins(); + _cachedTestCoinsEnabled = + (await _settingsRepository.loadSettings()).testCoinsEnabled; + final List coins = await _getOriginalCoinList( _coinsRepo, event.action, + cachedKnownCoinsMap: _cachedKnownCoinsMap, + cachedWalletCoins: _cachedWalletCoins, ); // Add wallet coins to selected coins if in add mode so that they @@ -295,8 +324,9 @@ class CoinsManagerBloc extends Bloc { } Future> _filterTestCoinsIfNeeded(List coins) async { - final settings = await _settingsRepository.loadSettings(); - return settings.testCoinsEnabled ? coins : removeTestCoins(coins); + _cachedTestCoinsEnabled ??= + (await _settingsRepository.loadSettings()).testCoinsEnabled; + return _cachedTestCoinsEnabled! ? coins : removeTestCoins(coins); } List _filterByPhrase(List coins) { @@ -324,7 +354,8 @@ class CoinsManagerBloc extends Bloc { return selectedCoins; } - final walletCoins = await _coinsRepo.getWalletCoins(); + _cachedWalletCoins ??= await _coinsRepo.getWalletCoins(); + final walletCoins = _cachedWalletCoins!; final result = List.from(selectedCoins); final selectedCoinIds = result.map((c) => c.id.id).toSet(); @@ -498,16 +529,18 @@ class CoinsManagerBloc extends Bloc { Future> _getOriginalCoinList( CoinsRepo coinsRepo, - CoinsManagerAction action, -) async { + CoinsManagerAction action, { + Map? cachedKnownCoinsMap, + List? cachedWalletCoins, +}) async { switch (action) { case CoinsManagerAction.add: - return coinsRepo - .getKnownCoinsMap(excludeExcludedAssets: true) - .values - .toList(); + final knownCoinsMap = + cachedKnownCoinsMap ?? + coinsRepo.getKnownCoinsMap(excludeExcludedAssets: true); + return knownCoinsMap.values.toList(); case CoinsManagerAction.remove: - return coinsRepo.getWalletCoins(); + return cachedWalletCoins ?? await coinsRepo.getWalletCoins(); case CoinsManagerAction.none: return []; } diff --git a/lib/bloc/taker_form/taker_bloc.dart b/lib/bloc/taker_form/taker_bloc.dart index 637fb0afab..727a70bde5 100644 --- a/lib/bloc/taker_form/taker_bloc.dart +++ b/lib/bloc/taker_form/taker_bloc.dart @@ -38,7 +38,7 @@ class TakerBloc extends Bloc { required AnalyticsBloc analyticsBloc, }) : _dexRepo = dexRepository, _coinsRepo = coinsRepository, - _kdfSdk = kdfSdk, + _sdk = kdfSdk, _analyticsBloc = analyticsBloc, super(TakerState.initial()) { _validator = TakerValidator( @@ -81,13 +81,17 @@ class TakerBloc extends Bloc { if (event != null && state.step == TakerStep.confirm) { add(TakerBackButtonClick()); } + if (event == null) { + add(TakerClear()); + add(TakerSetDefaults()); + } _isLoggedIn = event != null; }); } final DexRepository _dexRepo; final CoinsRepo _coinsRepo; - final KomodoDefiSdk _kdfSdk; + final KomodoDefiSdk _sdk; final AnalyticsBloc _analyticsBloc; Timer? _maxSellAmountTimer; bool _activatingAssets = false; @@ -119,7 +123,7 @@ class TakerBloc extends Bloc { // Log swap failure analytics event for immediate RPC errors final walletType = - (await _kdfSdk.auth.currentUser)?.wallet.config.type.name ?? + (await _sdk.auth.currentUser)?.wallet.config.type.name ?? 'unknown'; _analyticsBloc.logEvent( SwapFailedEventData( @@ -260,8 +264,14 @@ class TakerBloc extends Bloc { ); // Auto-fill the exact maker amount when an order is selected - if (event.order != null) { - add(TakerSetSellAmount(event.order!.maxVolume)); + final hasUserSetSellAmount = + (state.sellAmount ?? Rational.zero) > Rational.zero; + if (event.order != null && !hasUserSetSellAmount) { + final maxSellAmount = state.maxSellAmount ?? Rational.zero; + final desiredSellAmount = event.order!.maxVolume < maxSellAmount + ? event.order!.maxVolume + : maxSellAmount; + add(TakerSetSellAmount(desiredSellAmount)); } if (!state.autovalidate) add(TakerVerifyOrderVolume()); @@ -437,6 +447,25 @@ class TakerBloc extends Bloc { ); } + // Required here because of the manual RPC calls that bypass the sdk + final activeAssets = await _sdk.assets.getActivatedAssets(); + final isAssetActive = activeAssets.any( + (asset) => asset.id == state.sellCoin!.id, + ); + if (!isAssetActive) { + // Intentionally leave the state as loading so that a spinner is shown + // instead of a "0.00" balance hinting that the asset is active when it + // is not. + if (state.availableBalanceState != AvailableBalanceState.loading) { + emitter( + state.copyWith( + availableBalanceState: () => AvailableBalanceState.loading, + ), + ); + } + return; + } + if (!_isLoggedIn) { emitter( state.copyWith( @@ -475,11 +504,10 @@ class TakerBloc extends Bloc { try { return await retry( () => _dexRepo.getMaxTakerVolume(abbr), - maxAttempts: 5, + maxAttempts: 3, backoffStrategy: LinearBackoff( - initialDelay: const Duration(seconds: 2), - increment: const Duration(seconds: 2), - maxDelay: const Duration(seconds: 10), + initialDelay: const Duration(milliseconds: 500), + maxDelay: const Duration(seconds: 2), ), ); } catch (_) { diff --git a/lib/bloc/taker_form/taker_validator.dart b/lib/bloc/taker_form/taker_validator.dart index e52843a028..e636fd59fc 100644 --- a/lib/bloc/taker_form/taker_validator.dart +++ b/lib/bloc/taker_form/taker_validator.dart @@ -32,7 +32,7 @@ class TakerValidator { _coinsRepo = coinsRepo, _dexRepo = dexRepo, _sdk = sdk, - add = bloc.add; + add = bloc.add; final TakerBloc _bloc; final CoinsRepo _coinsRepo; @@ -316,7 +316,7 @@ class TakerValidator { } DexFormError _coinNotActiveError(String abbr) { - return DexFormError(error: '$abbr is not active.'); + return DexFormError(error: LocaleKeys.coinIsNotActive.tr(args: [abbr])); } DexFormError _selectSellCoinError() => diff --git a/lib/bloc/transaction_history/transaction_history_repo.dart b/lib/bloc/transaction_history/transaction_history_repo.dart index 19208222c5..755e2e2fc3 100644 --- a/lib/bloc/transaction_history/transaction_history_repo.dart +++ b/lib/bloc/transaction_history/transaction_history_repo.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:logging/logging.dart'; /// Throws [TransactionFetchException] if the transaction history could not be /// fetched. @@ -15,6 +16,8 @@ class SdkTransactionHistoryRepository implements TransactionHistoryRepo { required KomodoDefiSdk sdk, }) : _sdk = sdk; final KomodoDefiSdk _sdk; + final Logger _logger = + Logger('SdkTransactionHistoryRepository'); @override Future?> fetch(AssetId assetId, {String? fromId}) async { @@ -39,7 +42,8 @@ class SdkTransactionHistoryRepository implements TransactionHistoryRepo { ), ); return transactionHistory.transactions; - } catch (e) { + } catch (e, s) { + _logger.severe('Failed to fetch transactions for $assetId', e, s); return null; } } diff --git a/lib/bloc/version_info/version_info_bloc.dart b/lib/bloc/version_info/version_info_bloc.dart index d8c0e94f5f..62abe28808 100644 --- a/lib/bloc/version_info/version_info_bloc.dart +++ b/lib/bloc/version_info/version_info_bloc.dart @@ -48,11 +48,11 @@ class VersionInfoBloc extends Bloc { 'Commit: $commitHash', ); - final basicInfo = VersionInfoLoaded( + var currentInfo = VersionInfoLoaded( appVersion: appVersion, commitHash: commitHash, ); - emit(basicInfo); + emit(currentInfo); try { final apiVersion = await _mm2Api.version(); @@ -63,7 +63,8 @@ class VersionInfoBloc extends Bloc { final apiCommitHash = apiVersion != null ? () => _tryParseCommitHash(apiVersion) : null; - emit(basicInfo.copyWith(apiCommitHash: apiCommitHash)); + currentInfo = currentInfo.copyWith(apiCommitHash: apiCommitHash); + emit(currentInfo); _logger.info( 'MM2 API version loaded successfully - Version: $apiVersion, ' 'Commit: ${apiCommitHash?.call()}', @@ -83,12 +84,11 @@ class VersionInfoBloc extends Bloc { ); } - emit( - basicInfo.copyWith( - currentCoinsCommit: () => _tryParseCommitHash(currentCommit ?? '-'), - latestCoinsCommit: () => _tryParseCommitHash(latestCommit ?? '-'), - ), + currentInfo = currentInfo.copyWith( + currentCoinsCommit: () => _tryParseCommitHash(currentCommit ?? '-'), + latestCoinsCommit: () => _tryParseCommitHash(latestCommit ?? '-'), ); + emit(currentInfo); _logger.info( 'SDK coins commits loaded successfully - Current: $currentCommit, ' 'Latest: $latestCommit', diff --git a/lib/blocs/maker_form_bloc.dart b/lib/blocs/maker_form_bloc.dart index c40a435129..e24bd86807 100644 --- a/lib/blocs/maker_form_bloc.dart +++ b/lib/blocs/maker_form_bloc.dart @@ -275,6 +275,16 @@ class MakerFormBloc implements BlocBase { return; } + final activeAssets = await kdfSdk.assets.getActivatedAssets(); + final isAssetActive = activeAssets.any((asset) => asset.id == coin.id); + if (!isAssetActive) { + // Intentionally leave in the loading state to avoid showing a "0.00" balance + // while the asset is activating. + maxSellAmount = null; + availableBalanceState = AvailableBalanceState.loading; + return; + } + Rational? amount = await dexRepository.getMaxMakerVolume(coin.abbr); if (amount != null) { maxSellAmount = amount; diff --git a/lib/blocs/orderbook_bloc.dart b/lib/blocs/orderbook_bloc.dart index c5427de616..d06174ed88 100644 --- a/lib/blocs/orderbook_bloc.dart +++ b/lib/blocs/orderbook_bloc.dart @@ -1,13 +1,15 @@ import 'dart:async'; +import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart' + show OrderbookResponse; +import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:web_dex/blocs/bloc_base.dart'; -import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; -import 'package:web_dex/mm2/mm2_api/rpc/orderbook/orderbook_request.dart'; -import 'package:web_dex/mm2/mm2_api/rpc/orderbook/orderbook_response.dart'; +import 'package:web_dex/shared/utils/utils.dart'; class OrderbookBloc implements BlocBase { - OrderbookBloc({required Mm2Api api}) { - _api = api; + OrderbookBloc({required KomodoDefiSdk sdk}) { + _sdk = sdk; _timer = Timer.periodic( const Duration(seconds: 3), @@ -15,7 +17,7 @@ class OrderbookBloc implements BlocBase { ); } - late Mm2Api _api; + late KomodoDefiSdk _sdk; Timer? _timer; // keys are 'base/rel' Strings @@ -27,21 +29,21 @@ class OrderbookBloc implements BlocBase { _subscriptions.forEach((pair, subs) => subs.controller.close()); } - OrderbookResponse? getInitialData(String base, String rel) { + OrderbookResult? getInitialData(String base, String rel) { final String pair = '$base/$rel'; final OrderbookSubscription? subscription = _subscriptions[pair]; return subscription?.initialData; } - Stream getOrderbookStream(String base, String rel) { + Stream getOrderbookStream(String base, String rel) { final String pair = '$base/$rel'; final OrderbookSubscription? subscription = _subscriptions[pair]; if (subscription != null) { return subscription.stream; } else { - final controller = StreamController.broadcast(); + final controller = StreamController.broadcast(); final sink = controller.sink; final stream = controller.stream; @@ -58,7 +60,7 @@ class OrderbookBloc implements BlocBase { } Future _updateOrderbooks() async { - final List pairs = List.from(_subscriptions.keys); + final List pairs = List.of(_subscriptions.keys); for (String pair in pairs) { final OrderbookSubscription? subscription = _subscriptions[pair]; @@ -79,13 +81,25 @@ class OrderbookBloc implements BlocBase { final List coins = pair.split('/'); - final OrderbookResponse response = await _api.getOrderbook(OrderbookRequest( - base: coins[0], - rel: coins[1], - )); - - subscription.initialData = response; - subscription.sink.add(response); + try { + final OrderbookResponse response = await _sdk.client.rpc.orderbook + .orderbook(base: coins[0], rel: coins[1]); + + final result = OrderbookResult(response: response); + subscription.initialData = result; + subscription.sink.add(result); + } catch (e, s) { + log( + // Exception message can contain RPC pass, so avoid displaying it and logging it + 'Unexpected orderbook error for pair $pair', + path: 'OrderbookBloc._fetchOrderbook', + trace: s, + isError: true, + ).ignore(); + final result = OrderbookResult(error: 'Unexpected error for pair $pair'); + subscription.initialData = result; + subscription.sink.add(result); + } } } @@ -97,8 +111,17 @@ class OrderbookSubscription { required this.stream, }); - OrderbookResponse? initialData; - final StreamController controller; - final Sink sink; - final Stream stream; + OrderbookResult? initialData; + final StreamController controller; + final Sink sink; + final Stream stream; +} + +class OrderbookResult { + const OrderbookResult({this.response, this.error}); + + final OrderbookResponse? response; + final String? error; + + bool get hasError => error != null; } diff --git a/lib/generated/codegen_loader.g.dart b/lib/generated/codegen_loader.g.dart index dba0eb8329..a34c75bce0 100644 --- a/lib/generated/codegen_loader.g.dart +++ b/lib/generated/codegen_loader.g.dart @@ -2,7 +2,7 @@ // ignore_for_file: constant_identifier_names -abstract class LocaleKeys { +abstract class LocaleKeys { static const plsActivateKmd = 'plsActivateKmd'; static const rewardClaiming = 'rewardClaiming'; static const noKmdAddress = 'noKmdAddress'; @@ -106,25 +106,20 @@ abstract class LocaleKeys { static const seedPhrase = 'seedPhrase'; static const assetNumber = 'assetNumber'; static const clipBoard = 'clipBoard'; - static const walletsManagerCreateWalletButton = - 'walletsManagerCreateWalletButton'; - static const walletsManagerImportWalletButton = - 'walletsManagerImportWalletButton'; - static const walletsManagerStepBuilderCreationWalletError = - 'walletsManagerStepBuilderCreationWalletError'; + static const walletsManagerCreateWalletButton = 'walletsManagerCreateWalletButton'; + static const walletsManagerImportWalletButton = 'walletsManagerImportWalletButton'; + static const walletsManagerStepBuilderCreationWalletError = 'walletsManagerStepBuilderCreationWalletError'; static const walletCreationTitle = 'walletCreationTitle'; static const walletImportTitle = 'walletImportTitle'; static const walletImportByFileTitle = 'walletImportByFileTitle'; static const invalidWalletNameError = 'invalidWalletNameError'; static const invalidWalletFileNameError = 'invalidWalletFileNameError'; - static const walletImportCreatePasswordTitle = - 'walletImportCreatePasswordTitle'; + static const walletImportCreatePasswordTitle = 'walletImportCreatePasswordTitle'; static const walletImportByFileDescription = 'walletImportByFileDescription'; static const walletLogInTitle = 'walletLogInTitle'; static const walletCreationNameHint = 'walletCreationNameHint'; static const walletCreationPasswordHint = 'walletCreationPasswordHint'; - static const walletCreationConfirmPasswordHint = - 'walletCreationConfirmPasswordHint'; + static const walletCreationConfirmPasswordHint = 'walletCreationConfirmPasswordHint'; static const walletCreationConfirmPassword = 'walletCreationConfirmPassword'; static const walletCreationUploadFile = 'walletCreationUploadFile'; static const walletCreationEmptySeedError = 'walletCreationEmptySeedError'; @@ -143,19 +138,15 @@ abstract class LocaleKeys { static const passphraseCheckingTitle = 'passphraseCheckingTitle'; static const passphraseCheckingDescription = 'passphraseCheckingDescription'; static const passphraseCheckingEnterWord = 'passphraseCheckingEnterWord'; - static const passphraseCheckingEnterWordHint = - 'passphraseCheckingEnterWordHint'; + static const passphraseCheckingEnterWordHint = 'passphraseCheckingEnterWordHint'; static const back = 'back'; static const settingsMenuGeneral = 'settingsMenuGeneral'; static const settingsMenuLanguage = 'settingsMenuLanguage'; static const settingsMenuSecurity = 'settingsMenuSecurity'; static const settingsMenuAbout = 'settingsMenuAbout'; - static const seedPhraseSettingControlsViewSeed = - 'seedPhraseSettingControlsViewSeed'; - static const seedPhraseSettingControlsDownloadSeed = - 'seedPhraseSettingControlsDownloadSeed'; - static const debugSettingsResetActivatedCoins = - 'debugSettingsResetActivatedCoins'; + static const seedPhraseSettingControlsViewSeed = 'seedPhraseSettingControlsViewSeed'; + static const seedPhraseSettingControlsDownloadSeed = 'seedPhraseSettingControlsDownloadSeed'; + static const debugSettingsResetActivatedCoins = 'debugSettingsResetActivatedCoins'; static const debugSettingsDownloadButton = 'debugSettingsDownloadButton'; static const or = 'or'; static const passwordTitle = 'passwordTitle'; @@ -165,19 +156,16 @@ abstract class LocaleKeys { static const changePasswordSpan1 = 'changePasswordSpan1'; static const updatePassword = 'updatePassword'; static const passwordHasChanged = 'passwordHasChanged'; - static const confirmationForShowingSeedPhraseTitle = - 'confirmationForShowingSeedPhraseTitle'; + static const confirmationForShowingSeedPhraseTitle = 'confirmationForShowingSeedPhraseTitle'; static const saveAndRemember = 'saveAndRemember'; static const seedPhraseShowingTitle = 'seedPhraseShowingTitle'; static const seedPhraseShowingWarning = 'seedPhraseShowingWarning'; static const seedPhraseShowingShowPhrase = 'seedPhraseShowingShowPhrase'; static const seedPhraseShowingCopySeed = 'seedPhraseShowingCopySeed'; - static const seedPhraseShowingSavedPhraseButton = - 'seedPhraseShowingSavedPhraseButton'; + static const seedPhraseShowingSavedPhraseButton = 'seedPhraseShowingSavedPhraseButton'; static const seedAccessSpan1 = 'seedAccessSpan1'; static const backupSeedNotificationTitle = 'backupSeedNotificationTitle'; - static const backupSeedNotificationDescription = - 'backupSeedNotificationDescription'; + static const backupSeedNotificationDescription = 'backupSeedNotificationDescription'; static const backupSeedNotificationButton = 'backupSeedNotificationButton'; static const swapConfirmationTitle = 'swapConfirmationTitle'; static const swapConfirmationYouReceive = 'swapConfirmationYouReceive'; @@ -185,54 +173,41 @@ abstract class LocaleKeys { static const tradingDetailsTitleFailed = 'tradingDetailsTitleFailed'; static const tradingDetailsTitleCompleted = 'tradingDetailsTitleCompleted'; static const tradingDetailsTitleInProgress = 'tradingDetailsTitleInProgress'; - static const tradingDetailsTitleOrderMatching = - 'tradingDetailsTitleOrderMatching'; + static const tradingDetailsTitleOrderMatching = 'tradingDetailsTitleOrderMatching'; static const tradingDetailsTotalSpentTime = 'tradingDetailsTotalSpentTime'; - static const tradingDetailsTotalSpentTimeWithHours = - 'tradingDetailsTotalSpentTimeWithHours'; + static const tradingDetailsTotalSpentTimeWithHours = 'tradingDetailsTotalSpentTimeWithHours'; static const swapRecoverButtonTitle = 'swapRecoverButtonTitle'; static const swapRecoverButtonText = 'swapRecoverButtonText'; static const swapRecoverButtonErrorMessage = 'swapRecoverButtonErrorMessage'; - static const swapRecoverButtonSuccessMessage = - 'swapRecoverButtonSuccessMessage'; + static const swapRecoverButtonSuccessMessage = 'swapRecoverButtonSuccessMessage'; static const swapProgressStatusFailed = 'swapProgressStatusFailed'; static const swapDetailsStepStatusFailed = 'swapDetailsStepStatusFailed'; static const disclaimerAcceptEulaCheckbox = 'disclaimerAcceptEulaCheckbox'; - static const disclaimerAcceptTermsAndConditionsCheckbox = - 'disclaimerAcceptTermsAndConditionsCheckbox'; + static const disclaimerAcceptTermsAndConditionsCheckbox = 'disclaimerAcceptTermsAndConditionsCheckbox'; static const disclaimerAcceptDescription = 'disclaimerAcceptDescription'; - static const swapDetailsStepStatusInProcess = - 'swapDetailsStepStatusInProcess'; - static const swapDetailsStepStatusTimeSpent = - 'swapDetailsStepStatusTimeSpent'; + static const swapDetailsStepStatusInProcess = 'swapDetailsStepStatusInProcess'; + static const swapDetailsStepStatusTimeSpent = 'swapDetailsStepStatusTimeSpent'; static const milliseconds = 'milliseconds'; static const seconds = 'seconds'; static const minutes = 'minutes'; static const hours = 'hours'; - static const coinAddressDetailsNotificationTitle = - 'coinAddressDetailsNotificationTitle'; - static const coinAddressDetailsNotificationDescription = - 'coinAddressDetailsNotificationDescription'; + static const coinAddressDetailsNotificationTitle = 'coinAddressDetailsNotificationTitle'; + static const coinAddressDetailsNotificationDescription = 'coinAddressDetailsNotificationDescription'; static const swapFeeDetailsPaidFromBalance = 'swapFeeDetailsPaidFromBalance'; static const swapFeeDetailsSendCoinTxFee = 'swapFeeDetailsSendCoinTxFee'; - static const swapFeeDetailsReceiveCoinTxFee = - 'swapFeeDetailsReceiveCoinTxFee'; + static const swapFeeDetailsReceiveCoinTxFee = 'swapFeeDetailsReceiveCoinTxFee'; static const swapFeeDetailsTradingFee = 'swapFeeDetailsTradingFee'; - static const swapFeeDetailsSendTradingFeeTxFee = - 'swapFeeDetailsSendTradingFeeTxFee'; + static const swapFeeDetailsSendTradingFeeTxFee = 'swapFeeDetailsSendTradingFeeTxFee'; static const swapFeeDetailsNone = 'swapFeeDetailsNone'; - static const swapFeeDetailsPaidFromReceivedVolume = - 'swapFeeDetailsPaidFromReceivedVolume'; + static const swapFeeDetailsPaidFromReceivedVolume = 'swapFeeDetailsPaidFromReceivedVolume'; static const logoutPopupTitle = 'logoutPopupTitle'; - static const logoutPopupDescriptionWalletOnly = - 'logoutPopupDescriptionWalletOnly'; + static const logoutPopupDescriptionWalletOnly = 'logoutPopupDescriptionWalletOnly'; static const logoutPopupDescription = 'logoutPopupDescription'; static const transactionDetailsTitle = 'transactionDetailsTitle'; static const customSeedWarningText = 'customSeedWarningText'; static const customSeedIUnderstand = 'customSeedIUnderstand'; static const walletCreationBip39SeedError = 'walletCreationBip39SeedError'; - static const walletCreationHdBip39SeedError = - 'walletCreationHdBip39SeedError'; + static const walletCreationHdBip39SeedError = 'walletCreationHdBip39SeedError'; static const walletPageNoSuchAsset = 'walletPageNoSuchAsset'; static const swap = 'swap'; static const dexAddress = 'dexAddress'; @@ -308,8 +283,7 @@ abstract class LocaleKeys { static const sellCryptoDescription = 'sellCryptoDescription'; static const buy = 'buy'; static const changingWalletPassword = 'changingWalletPassword'; - static const changingWalletPasswordDescription = - 'changingWalletPasswordDescription'; + static const changingWalletPasswordDescription = 'changingWalletPasswordDescription'; static const dark = 'dark'; static const darkMode = 'darkMode'; static const light = 'light'; @@ -341,8 +315,7 @@ abstract class LocaleKeys { static const feedbackFormDiscord = 'feedbackFormDiscord'; static const feedbackFormMatrix = 'feedbackFormMatrix'; static const feedbackFormTelegram = 'feedbackFormTelegram'; - static const feedbackFormSelectContactMethod = - 'feedbackFormSelectContactMethod'; + static const feedbackFormSelectContactMethod = 'feedbackFormSelectContactMethod'; static const feedbackFormDiscordHint = 'feedbackFormDiscordHint'; static const feedbackFormMatrixHint = 'feedbackFormMatrixHint'; static const feedbackFormTelegramHint = 'feedbackFormTelegramHint'; @@ -354,8 +327,7 @@ abstract class LocaleKeys { static const contactRequiredError = 'contactRequiredError'; static const contactDetailsMaxLengthError = 'contactDetailsMaxLengthError'; static const discordUsernameValidatorError = 'discordUsernameValidatorError'; - static const telegramUsernameValidatorError = - 'telegramUsernameValidatorError'; + static const telegramUsernameValidatorError = 'telegramUsernameValidatorError'; static const matrixIdValidatorError = 'matrixIdValidatorError'; static const myCoinsMissing = 'myCoinsMissing'; static const myCoinsMissingReassurance = 'myCoinsMissingReassurance'; @@ -365,8 +337,7 @@ abstract class LocaleKeys { static const myCoinsMissingHelp = 'myCoinsMissingHelp'; static const myCoinsMissingSignIn = 'myCoinsMissingSignIn'; static const feedbackValidatorEmptyError = 'feedbackValidatorEmptyError'; - static const feedbackValidatorMaxLengthError = - 'feedbackValidatorMaxLengthError'; + static const feedbackValidatorMaxLengthError = 'feedbackValidatorMaxLengthError'; static const yourFeedback = 'yourFeedback'; static const sendFeedback = 'sendFeedback'; static const sendFeedbackError = 'sendFeedbackError'; @@ -386,7 +357,6 @@ abstract class LocaleKeys { static const backupSeedPhrase = 'backupSeedPhrase'; static const seedOr = 'seedOr'; static const seedDownload = 'seedDownload'; - static const seedSaveAndRemember = 'seedSaveAndRemember'; static const seedIntroWarning = 'seedIntroWarning'; static const seedSettings = 'seedSettings'; static const errorDescription = 'errorDescription'; @@ -406,8 +376,7 @@ abstract class LocaleKeys { static const trezorSelectTitle = 'trezorSelectTitle'; static const trezorSelectSubTitle = 'trezorSelectSubTitle'; static const trezorBrowserUnsupported = 'trezorBrowserUnsupported'; - static const trezorTransactionInProgressMessage = - 'trezorTransactionInProgressMessage'; + static const trezorTransactionInProgressMessage = 'trezorTransactionInProgressMessage'; static const mixedCaseError = 'mixedCaseError'; static const addressConvertedToMixedCase = 'addressConvertedToMixedCase'; static const invalidAddressChecksum = 'invalidAddressChecksum'; @@ -417,8 +386,7 @@ abstract class LocaleKeys { static const noSenderAddress = 'noSenderAddress'; static const confirmOnTrezor = 'confirmOnTrezor'; static const alphaVersionWarningTitle = 'alphaVersionWarningTitle'; - static const alphaVersionWarningDescription = - 'alphaVersionWarningDescription'; + static const alphaVersionWarningDescription = 'alphaVersionWarningDescription'; static const sendToAnalytics = 'sendToAnalytics'; static const backToWallet = 'backToWallet'; static const backToDex = 'backToDex'; @@ -443,14 +411,12 @@ abstract class LocaleKeys { static const currentPassword = 'currentPassword'; static const walletNotFound = 'walletNotFound'; static const passwordIsEmpty = 'passwordIsEmpty'; - static const passwordContainsTheWordPassword = - 'passwordContainsTheWordPassword'; + static const passwordContainsTheWordPassword = 'passwordContainsTheWordPassword'; static const passwordTooShort = 'passwordTooShort'; static const passwordMissingDigit = 'passwordMissingDigit'; static const passwordMissingLowercase = 'passwordMissingLowercase'; static const passwordMissingUppercase = 'passwordMissingUppercase'; - static const passwordMissingSpecialCharacter = - 'passwordMissingSpecialCharacter'; + static const passwordMissingSpecialCharacter = 'passwordMissingSpecialCharacter'; static const passwordConsecutiveCharacters = 'passwordConsecutiveCharacters'; static const passwordSecurity = 'passwordSecurity'; static const allowWeakPassword = 'allowWeakPassword'; @@ -479,20 +445,18 @@ abstract class LocaleKeys { static const bridgeMaxSendAmountError = 'bridgeMaxSendAmountError'; static const bridgeMinOrderAmountError = 'bridgeMinOrderAmountError'; static const bridgeMaxOrderAmountError = 'bridgeMaxOrderAmountError'; - static const bridgeInsufficientBalanceError = - 'bridgeInsufficientBalanceError'; + static const bridgeInsufficientBalanceError = 'bridgeInsufficientBalanceError'; static const lowTradeVolumeError = 'lowTradeVolumeError'; static const bridgeSelectReceiveCoinError = 'bridgeSelectReceiveCoinError'; static const withdrawNoParentCoinError = 'withdrawNoParentCoinError'; static const withdrawTopUpBalanceError = 'withdrawTopUpBalanceError'; - static const withdrawNotEnoughBalanceForGasError = - 'withdrawNotEnoughBalanceForGasError'; - static const withdrawNotSufficientBalanceError = - 'withdrawNotSufficientBalanceError'; + static const withdrawNotEnoughBalanceForGasError = 'withdrawNotEnoughBalanceForGasError'; + static const withdrawNotSufficientBalanceError = 'withdrawNotSufficientBalanceError'; static const withdrawZeroBalanceError = 'withdrawZeroBalanceError'; static const withdrawAmountTooLowError = 'withdrawAmountTooLowError'; static const withdrawNoSuchCoinError = 'withdrawNoSuchCoinError'; static const withdrawPreview = 'withdrawPreview'; + static const withdrawPreviewZhtlcNote = 'withdrawPreviewZhtlcNote'; static const withdrawPreviewError = 'withdrawPreviewError'; static const txHistoryFetchError = 'txHistoryFetchError'; static const txHistoryNoTransactions = 'txHistoryNoTransactions'; @@ -543,6 +507,7 @@ abstract class LocaleKeys { static const userActionRequired = 'userActionRequired'; static const unknown = 'unknown'; static const unableToActiveCoin = 'unableToActiveCoin'; + static const coinIsNotActive = 'coinIsNotActive'; static const feedback = 'feedback'; static const feedbackViewTitle = 'feedbackViewTitle'; static const feedbackPageDescription = 'feedbackPageDescription'; @@ -560,10 +525,8 @@ abstract class LocaleKeys { static const availableForSwaps = 'availableForSwaps'; static const swapNow = 'swapNow'; static const passphrase = 'passphrase'; - static const enterPassphraseHiddenWalletTitle = - 'enterPassphraseHiddenWalletTitle'; - static const enterPassphraseHiddenWalletDescription = - 'enterPassphraseHiddenWalletDescription'; + static const enterPassphraseHiddenWalletTitle = 'enterPassphraseHiddenWalletTitle'; + static const enterPassphraseHiddenWalletDescription = 'enterPassphraseHiddenWalletDescription'; static const skip = 'skip'; static const activateToSeeFunds = 'activateToSeeFunds'; static const useCustomSeedOrWif = 'useCustomSeedOrWif'; @@ -590,15 +553,13 @@ abstract class LocaleKeys { static const downloadAllKeys = 'downloadAllKeys'; static const shareAllKeys = 'shareAllKeys'; static const confirmPrivateKeyBackup = 'confirmPrivateKeyBackup'; - static const confirmPrivateKeyBackupDescription = - 'confirmPrivateKeyBackupDescription'; + static const confirmPrivateKeyBackupDescription = 'confirmPrivateKeyBackupDescription'; static const importantSecurityNotice = 'importantSecurityNotice'; static const privateKeySecurityWarning = 'privateKeySecurityWarning'; static const privateKeyBackupConfirmation = 'privateKeyBackupConfirmation'; static const confirmBackupComplete = 'confirmBackupComplete'; static const privateKeyExportSuccessTitle = 'privateKeyExportSuccessTitle'; - static const privateKeyExportSuccessDescription = - 'privateKeyExportSuccessDescription'; + static const privateKeyExportSuccessDescription = 'privateKeyExportSuccessDescription'; static const iHaveSavedMyPrivateKeys = 'iHaveSavedMyPrivateKeys'; static const copyWarning = 'copyWarning'; static const seedConfirmTitle = 'seedConfirmTitle'; @@ -646,10 +607,8 @@ abstract class LocaleKeys { static const collectibles = 'collectibles'; static const sendingProcess = 'sendingProcess'; static const ercStandardDisclaimer = 'ercStandardDisclaimer'; - static const nftReceiveNonSwapAddressWarning = - 'nftReceiveNonSwapAddressWarning'; - static const nftReceiveNonSwapWalletDetails = - 'nftReceiveNonSwapWalletDetails'; + static const nftReceiveNonSwapAddressWarning = 'nftReceiveNonSwapAddressWarning'; + static const nftReceiveNonSwapWalletDetails = 'nftReceiveNonSwapWalletDetails'; static const nftMainLoggedOut = 'nftMainLoggedOut'; static const confirmLogoutOnAnotherTab = 'confirmLogoutOnAnotherTab'; static const refreshList = 'refreshList'; @@ -663,10 +622,8 @@ abstract class LocaleKeys { static const noWalletsAvailable = 'noWalletsAvailable'; static const selectWalletToReset = 'selectWalletToReset'; static const qrScannerTitle = 'qrScannerTitle'; - static const qrScannerErrorControllerUninitialized = - 'qrScannerErrorControllerUninitialized'; - static const qrScannerErrorPermissionDenied = - 'qrScannerErrorPermissionDenied'; + static const qrScannerErrorControllerUninitialized = 'qrScannerErrorControllerUninitialized'; + static const qrScannerErrorPermissionDenied = 'qrScannerErrorPermissionDenied'; static const qrScannerErrorGenericError = 'qrScannerErrorGenericError'; static const qrScannerErrorTitle = 'qrScannerErrorTitle'; static const spend = 'spend'; @@ -711,8 +668,7 @@ abstract class LocaleKeys { static const fiatPaymentInProgressMessage = 'fiatPaymentInProgressMessage'; static const pleaseWait = 'pleaseWait'; static const bitrefillPaymentSuccessfull = 'bitrefillPaymentSuccessfull'; - static const bitrefillPaymentSuccessfullInstruction = - 'bitrefillPaymentSuccessfullInstruction'; + static const bitrefillPaymentSuccessfullInstruction = 'bitrefillPaymentSuccessfullInstruction'; static const tradingBot = 'tradingBot'; static const margin = 'margin'; static const updateInterval = 'updateInterval'; @@ -801,4 +757,26 @@ abstract class LocaleKeys { static const fetchingPrivateKeysMessage = 'fetchingPrivateKeysMessage'; static const pubkeyType = 'pubkeyType'; static const securitySettings = 'securitySettings'; + static const zhtlcConfigureTitle = 'zhtlcConfigureTitle'; + static const zhtlcZcashParamsPathLabel = 'zhtlcZcashParamsPathLabel'; + static const zhtlcPathAutomaticallyDetected = 'zhtlcPathAutomaticallyDetected'; + static const zhtlcSaplingParamsFolder = 'zhtlcSaplingParamsFolder'; + static const zhtlcBlocksPerIterationLabel = 'zhtlcBlocksPerIterationLabel'; + static const zhtlcScanIntervalLabel = 'zhtlcScanIntervalLabel'; + static const zhtlcStartSyncFromLabel = 'zhtlcStartSyncFromLabel'; + static const zhtlcEarliestSaplingOption = 'zhtlcEarliestSaplingOption'; + static const zhtlcBlockHeightOption = 'zhtlcBlockHeightOption'; + static const zhtlcShieldedAddress = 'zhtlcShieldedAddress'; + static const zhtlcDateTimeOption = 'zhtlcDateTimeOption'; + static const zhtlcSelectDateTimeLabel = 'zhtlcSelectDateTimeLabel'; + static const zhtlcZcashParamsRequired = 'zhtlcZcashParamsRequired'; + static const zhtlcInvalidBlockHeight = 'zhtlcInvalidBlockHeight'; + static const zhtlcSelectDateTimeRequired = 'zhtlcSelectDateTimeRequired'; + static const zhtlcDownloadingZcashParams = 'zhtlcDownloadingZcashParams'; + static const zhtlcPreparingDownload = 'zhtlcPreparingDownload'; + static const zhtlcErrorSettingUpZcash = 'zhtlcErrorSettingUpZcash'; + static const zhtlcDateSyncHint = 'zhtlcDateSyncHint'; + static const zhtlcActivating = 'zhtlcActivating'; + static const zhtlcActivationWarning = 'zhtlcActivationWarning'; + } diff --git a/lib/main.dart b/lib/main.dart index 8e8a22f76d..392f3e3ae1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -30,11 +30,12 @@ import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; import 'package:web_dex/model/stored_settings.dart'; import 'package:web_dex/performance_analytics/performance_analytics.dart'; import 'package:web_dex/sdk/widgets/window_close_handler.dart'; +import 'package:web_dex/services/arrr_activation/arrr_activation_service.dart'; import 'package:web_dex/services/feedback/app_feedback_wrapper.dart'; import 'package:web_dex/services/logger/get_logger.dart'; -import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; import 'package:web_dex/services/storage/get_storage.dart'; import 'package:web_dex/shared/constants.dart'; +import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; import 'package:web_dex/shared/utils/platform_tuner.dart'; import 'package:web_dex/shared/utils/utils.dart'; @@ -73,11 +74,13 @@ Future main() async { final tradingStatusRepository = TradingStatusRepository(komodoDefiSdk); final tradingStatusService = TradingStatusService(tradingStatusRepository); await tradingStatusService.initialize(); + final arrrActivationService = ArrrActivationService(komodoDefiSdk); final coinsRepo = CoinsRepo( kdfSdk: komodoDefiSdk, mm2: mm2, tradingStatusService: tradingStatusService, + arrrActivationService: arrrActivationService, ); final walletsRepository = WalletsRepository( komodoDefiSdk, @@ -96,6 +99,7 @@ Future main() async { providers: [ RepositoryProvider.value(value: komodoDefiSdk), RepositoryProvider.value(value: mm2Api), + RepositoryProvider.value(value: arrrActivationService), RepositoryProvider.value(value: coinsRepo), RepositoryProvider.value(value: walletsRepository), RepositoryProvider.value(value: sparklineRepository), diff --git a/lib/mm2/mm2_api/mm2_api.dart b/lib/mm2/mm2_api/mm2_api.dart index 8fcaa1ffa2..12f9a03dac 100644 --- a/lib/mm2/mm2_api/mm2_api.dart +++ b/lib/mm2/mm2_api/mm2_api.dart @@ -33,8 +33,6 @@ import 'package:web_dex/mm2/mm2_api/rpc/my_tx_history/my_tx_history_request.dart import 'package:web_dex/mm2/mm2_api/rpc/my_tx_history/my_tx_history_v2_request.dart'; import 'package:web_dex/mm2/mm2_api/rpc/order_status/order_status_request.dart'; import 'package:web_dex/mm2/mm2_api/rpc/order_status/order_status_response.dart'; -import 'package:web_dex/mm2/mm2_api/rpc/orderbook/orderbook_request.dart'; -import 'package:web_dex/mm2/mm2_api/rpc/orderbook/orderbook_response.dart'; import 'package:web_dex/mm2/mm2_api/rpc/orderbook_depth/orderbook_depth_req.dart'; import 'package:web_dex/mm2/mm2_api/rpc/orderbook_depth/orderbook_depth_response.dart'; import 'package:web_dex/mm2/mm2_api/rpc/recover_funds_of_swap/recover_funds_of_swap_request.dart'; @@ -52,16 +50,13 @@ import 'package:web_dex/mm2/mm2_api/rpc/trade_preimage/trade_preimage_response.d import 'package:web_dex/mm2/mm2_api/rpc/validateaddress/validateaddress_request.dart'; import 'package:web_dex/mm2/mm2_api/rpc/version/version_response.dart'; import 'package:web_dex/mm2/mm2_api/rpc/withdraw/withdraw_request.dart'; -import 'package:web_dex/model/orderbook/orderbook.dart'; import 'package:web_dex/model/text_error.dart'; import 'package:web_dex/shared/utils/utils.dart'; class Mm2Api { - Mm2Api({ - required MM2 mm2, - required KomodoDefiSdk sdk, - }) : _sdk = sdk, - _mm2 = mm2 { + Mm2Api({required MM2 mm2, required KomodoDefiSdk sdk}) + : _sdk = sdk, + _mm2 = mm2 { nft = Mm2ApiNft(_mm2.call, sdk); } @@ -113,10 +108,7 @@ class Mm2Api { denom: rational.denominator.toString(), ); - return MaxTakerVolResponse( - coin: abbr, - result: result, - ); + return MaxTakerVolResponse(coin: abbr, result: result); } Future?> getActiveSwaps( @@ -478,11 +470,7 @@ class Mm2Api { denom: rational.denominator.toString(), ); - return MaxMakerVolResponse( - coin: coinAbbr, - volume: result, - balance: result, - ); + return MaxMakerVolResponse(coin: coinAbbr, volume: result, balance: result); } Future getMinTradingVol( @@ -505,36 +493,6 @@ class Mm2Api { } } - Future getOrderbook(OrderbookRequest request) async { - try { - final JsonMap json = await _mm2.call(request); - - if (json['error'] != null) { - return OrderbookResponse( - request: request, - error: json['error'] as String?, - ); - } - - return OrderbookResponse( - request: request, - result: Orderbook.fromJson(json), - ); - } catch (e, s) { - log( - 'Error getting orderbook ${request.base}/${request.rel}: $e', - path: 'api => getOrderbook', - trace: s, - isError: true, - ).ignore(); - - return OrderbookResponse( - request: request, - error: e.toString(), - ); - } - } - Future getOrderBookDepth( List> pairs, CoinsRepo coinsRepository, @@ -557,10 +515,13 @@ class Mm2Api { } Future< - ApiResponse>> getTradePreimage( - TradePreimageRequest request, - ) async { + ApiResponse< + TradePreimageRequest, + TradePreimageResponseResult, + Map + > + > + getTradePreimage(TradePreimageRequest request) async { try { final JsonMap responseJson = await _mm2.call(request); if (responseJson['error'] != null) { @@ -577,9 +538,7 @@ class Mm2Api { trace: s, isError: true, ).ignore(); - return ApiResponse( - request: request, - ); + return ApiResponse(request: request); } } @@ -635,9 +594,7 @@ class Mm2Api { await _mm2.call(StopReq()); } - Future showPrivKey( - ShowPrivKeyRequest request, - ) async { + Future showPrivKey(ShowPrivKeyRequest request) async { try { final JsonMap json = await _mm2.call(request); if (json['error'] != null) { diff --git a/lib/mm2/mm2_api/rpc/orderbook/orderbook_request.dart b/lib/mm2/mm2_api/rpc/orderbook/orderbook_request.dart deleted file mode 100644 index d742cb063e..0000000000 --- a/lib/mm2/mm2_api/rpc/orderbook/orderbook_request.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; - -class OrderbookRequest implements BaseRequest { - OrderbookRequest({ - required this.base, - required this.rel, - }); - - final String base; - final String rel; - @override - late String userpass; - @override - final String method = 'orderbook'; - - @override - Map toJson() => { - 'userpass': userpass, - 'method': method, - 'base': base, - 'rel': rel, - }; -} diff --git a/lib/mm2/mm2_api/rpc/orderbook/orderbook_response.dart b/lib/mm2/mm2_api/rpc/orderbook/orderbook_response.dart deleted file mode 100644 index 55fa0e5118..0000000000 --- a/lib/mm2/mm2_api/rpc/orderbook/orderbook_response.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; -import 'package:web_dex/mm2/mm2_api/rpc/orderbook/orderbook_request.dart'; -import 'package:web_dex/model/orderbook/orderbook.dart'; - -class OrderbookResponse - implements ApiResponse { - OrderbookResponse({required this.request, this.result, this.error}); - - @override - final OrderbookRequest request; - @override - final Orderbook? result; - @override - final String? error; -} diff --git a/lib/model/coin_type.dart b/lib/model/coin_type.dart index b10ac1a6bc..9ee370e8c5 100644 --- a/lib/model/coin_type.dart +++ b/lib/model/coin_type.dart @@ -19,4 +19,5 @@ enum CoinType { tendermintToken, tendermint, slp, + zhtlc, } diff --git a/lib/model/coin_utils.dart b/lib/model/coin_utils.dart index 7fee037ab8..afc400cb84 100644 --- a/lib/model/coin_utils.dart +++ b/lib/model/coin_utils.dart @@ -11,7 +11,7 @@ import 'package:web_dex/shared/utils/utils.dart'; /// 2. If no balance, sort by priority (higher priority first) /// 3. If same priority, sort alphabetically List sortByPriorityAndBalance(List coins, KomodoDefiSdk sdk) { - final List list = List.from(coins); + final List list = List.of(coins); list.sort((a, b) { final double usdBalanceA = a.lastKnownUsdBalance(sdk) ?? 0.00; final double usdBalanceB = b.lastKnownUsdBalance(sdk) ?? 0.00; @@ -36,7 +36,7 @@ List sortByPriorityAndBalance(List coins, KomodoDefiSdk sdk) { } List sortFiatBalance(List coins, KomodoDefiSdk sdk) { - final List list = List.from(coins); + final List list = List.of(coins); list.sort((a, b) { final double usdBalanceA = a.lastKnownUsdBalance(sdk) ?? 0.00; final double usdBalanceB = b.lastKnownUsdBalance(sdk) ?? 0.00; @@ -57,28 +57,11 @@ List sortFiatBalance(List coins, KomodoDefiSdk sdk) { } List removeTestCoins(List coins) { - final List list = List.from(coins); - - list.removeWhere((Coin coin) => coin.isTestCoin); - - return list; + return coins.where((Coin coin) => !coin.isTestCoin).toList(); } List removeWalletOnly(List coins) { - final List list = List.from(coins); - - list.removeWhere((Coin coin) => coin.walletOnly); - - return list; -} - -List removeSuspended(List coins, bool isLoggedIn) { - if (!isLoggedIn) return coins; - final List list = List.from(coins); - - list.removeWhere((Coin coin) => coin.isSuspended); - - return list; + return coins.where((Coin coin) => !coin.walletOnly).toList(); } Map> removeSingleProtocol(Map> group) { @@ -188,6 +171,8 @@ String getCoinTypeName(CoinType type, [String? symbol]) { return 'Tendermint Token'; case CoinType.slp: return 'SLP'; + case CoinType.zhtlc: + return 'ZHTLC'; } } diff --git a/lib/model/kdf_auth_metadata_extension.dart b/lib/model/kdf_auth_metadata_extension.dart index 1e1a549a65..27dfa875ad 100644 --- a/lib/model/kdf_auth_metadata_extension.dart +++ b/lib/model/kdf_auth_metadata_extension.dart @@ -1,10 +1,13 @@ import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart' show Asset; +import 'package:logging/logging.dart'; import 'package:web_dex/bloc/coins_bloc/asset_coin_extension.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/wallet.dart'; +final Logger _walletMetadataLog = Logger('KdfAuthMetadataExtension'); + extension KdfAuthMetadataExtension on KomodoDefiSdk { /// Checks if a wallet with the specified ID exists in the system. /// @@ -31,23 +34,65 @@ extension KdfAuthMetadataExtension on KomodoDefiSdk { return user?.metadata.valueOrNull>('activated_coins') ?? []; } + /// Returns the stored list of wallet assets resolved from configuration IDs. + /// + /// Missing assets (for example, delisted coins) are skipped and logged for + /// visibility. + /// + /// Throws [StateError] if multiple assets are found with the same configuration ID. + Future> getWalletAssets() async { + final coinIds = await getWalletCoinIds(); + if (coinIds.isEmpty) { + return []; + } + + final missingCoinIds = {}; + final walletAssets = []; + + for (final coinId in coinIds) { + final matchingAssets = assets.findAssetsByConfigId(coinId); + if (matchingAssets.isEmpty) { + missingCoinIds.add(coinId); + continue; + } + + if (matchingAssets.length > 1) { + final assetIds = matchingAssets.map((asset) => asset.id.id).join(', '); + final message = + 'Multiple assets found for activated coin ID "$coinId": $assetIds'; + _walletMetadataLog.shout(message); + throw StateError(message); + } + + walletAssets.add(matchingAssets.single); + } + + if (missingCoinIds.isNotEmpty) { + _walletMetadataLog.warning( + 'Skipping ${missingCoinIds.length} activated coin(s) that are no longer ' + 'available in the SDK (likely delisted): ' + '${missingCoinIds.join(', ')}', + ); + } + + return walletAssets; + } + /// Returns the stored list of wallet coins converted from asset configuration IDs. /// /// This method retrieves the coin IDs from user metadata and converts them /// to [Coin] objects. Uses `single` to maintain existing behavior which will /// throw an exception if multiple assets share the same ticker. /// + /// Missing assets (for example, delisted coins) are skipped and logged for + /// visibility. + /// /// If no user is signed in, returns an empty list. /// /// Throws [StateError] if multiple assets are found with the same configuration ID. Future> getWalletCoins() async { - final coinIds = await getWalletCoinIds(); - return coinIds - // use single to stick to the existing behaviour around assetByTicker - // which will cause the application to crash if there are - // multiple assets with the same ticker - .map((coinId) => assets.findAssetsByConfigId(coinId).single.toCoin()) - .toList(); + final walletAssets = await getWalletAssets(); + return walletAssets.map((asset) => asset.toCoin()).toList(); } /// Adds new coin/asset IDs to the current user's activated coins list. diff --git a/lib/model/orderbook/order.dart b/lib/model/orderbook/order.dart index f80b63c0dc..92be3fcaf7 100644 --- a/lib/model/orderbook/order.dart +++ b/lib/model/orderbook/order.dart @@ -1,3 +1,5 @@ +import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart' + show NumericValue, OrderInfo; import 'package:rational/rational.dart'; import 'package:uuid/uuid.dart'; import 'package:web_dex/shared/utils/utils.dart'; @@ -29,15 +31,53 @@ class Order { uuid: json['uuid'], pubkey: json['pubkey'], price: fract2rat(json['price_fraction']) ?? Rational.parse(json['price']), - maxVolume: fract2rat(json['base_max_volume_fraction']) ?? + maxVolume: + fract2rat(json['base_max_volume_fraction']) ?? Rational.parse(json['base_max_volume']), - minVolume: fract2rat(json['base_min_volume_fraction']) ?? + minVolume: + fract2rat(json['base_min_volume_fraction']) ?? Rational.parse(json['base_min_volume']), - minVolumeRel: fract2rat(json['rel_min_volume_fraction']) ?? + minVolumeRel: + fract2rat(json['rel_min_volume_fraction']) ?? Rational.parse(json['rel_min_volume']), ); } + factory Order.fromOrderInfo( + OrderInfo info, { + required String base, + required String rel, + required OrderDirection direction, + }) { + final Rational? price = info.price?.toRational(); + + final Rational? maxVolume = + (info.baseMaxVolume ?? info.baseMaxVolumeAggregated)?.toRational(); + + if (price == null || maxVolume == null) { + throw ArgumentError('Invalid price or maxVolume in OrderInfo'); + } + + final Rational? minVolume = info.baseMinVolume?.toRational(); + + final Rational? minVolumeRel = + info.relMinVolume?.toRational() ?? + (minVolume != null ? minVolume * price : null); + + return Order( + base: base, + rel: rel, + direction: direction, + price: price, + maxVolume: maxVolume, + address: info.address?.addressData, + uuid: info.uuid, + pubkey: info.pubkey, + minVolume: minVolume, + minVolumeRel: minVolumeRel, + ); + } + final String base; final String rel; final OrderDirection direction; @@ -58,3 +98,22 @@ enum OrderDirection { bid, ask } // This const is used to identify and highlight newly created // order preview in maker form orderbook (instead of isTarget flag) final String orderPreviewUuid = const Uuid().v1(); + +extension NumericValueExtension on NumericValue { + Rational toRational() { + if (rational != null) { + return rational!; + } + if (fraction != null) { + final fractionRat = fract2rat(fraction!.toJson(), false); + if (fractionRat != null) { + return fractionRat; + } + } + final decimal = this.decimal.trim(); + if (decimal.isEmpty) { + throw ArgumentError('NumericValue has empty decimal string'); + } + return Rational.parse(decimal); + } +} diff --git a/lib/model/orderbook/orderbook.dart b/lib/model/orderbook/orderbook.dart index 386ba0dcfb..af1c3b57fd 100644 --- a/lib/model/orderbook/orderbook.dart +++ b/lib/model/orderbook/orderbook.dart @@ -1,3 +1,4 @@ +import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart' as sdk; import 'package:rational/rational.dart'; import 'package:web_dex/model/orderbook/order.dart'; import 'package:web_dex/shared/utils/utils.dart'; @@ -20,31 +21,86 @@ class Orderbook { base: json['base'], rel: json['rel'], asks: json['asks'] - .map((dynamic item) => Order.fromJson( - item, - direction: OrderDirection.ask, - otherCoin: json['rel'], - )) + .map( + (dynamic item) => Order.fromJson( + item, + direction: OrderDirection.ask, + otherCoin: json['rel'], + ), + ) .toList(), bids: json['bids'] - .map((dynamic item) => Order.fromJson( - item, - direction: OrderDirection.bid, - otherCoin: json['base'], - )) + .map( + (dynamic item) => Order.fromJson( + item, + direction: OrderDirection.bid, + otherCoin: json['base'], + ), + ) .toList(), - bidsBaseVolTotal: fract2rat(json['total_bids_base_vol_fraction']) ?? + bidsBaseVolTotal: + fract2rat(json['total_bids_base_vol_fraction']) ?? Rational.parse(json['total_bids_base_vol']), - bidsRelVolTotal: fract2rat(json['total_bids_rel_vol_fraction']) ?? + bidsRelVolTotal: + fract2rat(json['total_bids_rel_vol_fraction']) ?? Rational.parse(json['total_bids_rel_vol']), - asksBaseVolTotal: fract2rat(json['total_asks_base_vol_fraction']) ?? + asksBaseVolTotal: + fract2rat(json['total_asks_base_vol_fraction']) ?? Rational.parse(json['total_asks_base_vol']), - asksRelVolTotal: fract2rat(json['total_asks_rel_vol_fraction']) ?? + asksRelVolTotal: + fract2rat(json['total_asks_rel_vol_fraction']) ?? Rational.parse(json['total_asks_rel_vol']), timestamp: json['timestamp'], ); } + factory Orderbook.fromSdkResponse(sdk.OrderbookResponse response) { + List _mapOrders( + List orders, + OrderDirection direction, + ) { + return orders + .map( + (info) => Order.fromOrderInfo( + info, + base: response.base, + rel: response.rel, + direction: direction, + ), + ) + .toList(); + } + + final asks = _mapOrders(response.asks, OrderDirection.ask); + final bids = _mapOrders(response.bids, OrderDirection.bid); + + Rational _totalBaseVolume(List orders) { + return orders.fold( + Rational.zero, + (sum, order) => sum + order.maxVolume, + ); + } + + Rational _totalRelVolume(List orders) { + return orders.fold( + Rational.zero, + (sum, order) => sum + (order.maxVolume * order.price), + ); + } + + return Orderbook( + base: response.base, + rel: response.rel, + bidsBaseVolTotal: _totalBaseVolume(bids), + bidsRelVolTotal: _totalRelVolume(bids), + asksBaseVolTotal: _totalBaseVolume(asks), + asksRelVolTotal: _totalRelVolume(asks), + bids: bids, + asks: asks, + timestamp: response.timestamp, + ); + } + final String base; final String rel; final List bids; diff --git a/lib/model/orderbook_model.dart b/lib/model/orderbook_model.dart index 2d0e0e0470..930c77302a 100644 --- a/lib/model/orderbook_model.dart +++ b/lib/model/orderbook_model.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:web_dex/blocs/orderbook_bloc.dart'; -import 'package:web_dex/mm2/mm2_api/rpc/orderbook/orderbook_response.dart'; import 'package:web_dex/model/coin.dart'; class OrderbookModel { @@ -33,12 +32,12 @@ class OrderbookModel { StreamSubscription? _orderbookListener; - OrderbookResponse? _response; - final _responseCtrl = StreamController.broadcast(); - Sink get _inResponse => _responseCtrl.sink; - Stream get outResponse => _responseCtrl.stream; - OrderbookResponse? get response => _response; - set response(OrderbookResponse? value) { + OrderbookResult? _response; + final _responseCtrl = StreamController.broadcast(); + Sink get _inResponse => _responseCtrl.sink; + Stream get outResponse => _responseCtrl.stream; + OrderbookResult? get response => _response; + set response(OrderbookResult? value) { _response = value; _inResponse.add(_response); } @@ -59,8 +58,10 @@ class OrderbookModel { response = null; if (base == null || rel == null) return; - final stream = - orderBookRepository.getOrderbookStream(base!.abbr, rel!.abbr); + final stream = orderBookRepository.getOrderbookStream( + base!.abbr, + rel!.abbr, + ); _orderbookListener = stream.listen((resp) => response = resp); } diff --git a/lib/services/arrr_activation/arrr_activation_service.dart b/lib/services/arrr_activation/arrr_activation_service.dart new file mode 100644 index 0000000000..a843b14011 --- /dev/null +++ b/lib/services/arrr_activation/arrr_activation_service.dart @@ -0,0 +1,492 @@ +import 'dart:async'; + +import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; +import 'package:komodo_defi_types/komodo_defi_type_utils.dart' + show ExponentialBackoff, retry; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:logging/logging.dart'; +import 'package:mutex/mutex.dart'; + +import 'arrr_config.dart'; + +/// Service layer - business logic coordination for ARRR activation +class ArrrActivationService { + ArrrActivationService(this._sdk) + : _configService = _sdk.activationConfigService { + _startListeningToAuthChanges(); + } + + final ActivationConfigService _configService; + final KomodoDefiSdk _sdk; + final Logger _log = Logger('ArrrActivationService'); + + /// Stream controller for configuration requests + final StreamController _configRequestController = + StreamController.broadcast(); + + /// Completer to wait for configuration when needed + final Map> _configCompleters = {}; + + /// Track ongoing activation flows per asset to prevent duplicate runs + final Map> _ongoingActivations = {}; + + /// Subscription to auth state changes + StreamSubscription? _authSubscription; + + /// Flag to track if the service is being disposed + bool _isDisposing = false; + + /// Stream of configuration requests that UI can listen to + Stream get configurationRequests => + _configRequestController.stream; + + /// Future-based activation (for CoinsRepo consumers) + /// This method will wait for user configuration if needed + Future activateArrr( + Asset asset, { + ZhtlcUserConfig? initialConfig, + }) { + if (_isDisposing || _configRequestController.isClosed) { + throw StateError('ArrrActivationService has been disposed'); + } + + final existingActivation = _ongoingActivations[asset.id]; + if (existingActivation != null) { + _log.info( + 'Activation already in progress for ${asset.id.id} - reusing existing future', + ); + return existingActivation; + } + + late Future activationFuture; + activationFuture = + _activateArrrInternal(asset, initialConfig: initialConfig).whenComplete( + () { + _ongoingActivations.remove(asset.id); + }, + ); + _ongoingActivations[asset.id] = activationFuture; + return activationFuture; + } + + Future _activateArrrInternal( + Asset asset, { + ZhtlcUserConfig? initialConfig, + }) async { + var config = initialConfig ?? await _getOrRequestConfiguration(asset.id); + + if (config == null) { + final requiredSettings = await _getRequiredSettings(asset.id); + + final configRequest = ZhtlcConfigurationRequest( + asset: asset, + requiredSettings: requiredSettings, + ); + + final completer = Completer(); + _configCompleters[asset.id] = completer; + + _log.info('Requesting configuration for ${asset.id.id}'); + + // Check if stream controller is closed or service is disposing + if (_isDisposing || _configRequestController.isClosed) { + _log.severe( + 'Configuration request controller is closed or service is disposing for ${asset.id.id}', + ); + _configCompleters.remove(asset.id); + return ArrrActivationResultError( + 'Configuration system is not available', + ); + } + + // Wait for UI listeners to be ready before emitting request + await _waitForUIListeners(asset.id); + + try { + _configRequestController.add(configRequest); + _log.info('Configuration request emitted for ${asset.id.id}'); + } catch (e, stackTrace) { + _log.severe( + 'Failed to emit configuration request for ${asset.id.id}', + e, + stackTrace, + ); + return ArrrActivationResultError('Failed to request configuration: $e'); + } + + try { + config = await completer.future.timeout( + const Duration(minutes: 15), + onTimeout: () { + _log.warning('Configuration request timed out for ${asset.id.id}'); + return null; + }, + ); + } finally { + _configCompleters.remove(asset.id); + } + + if (config == null) { + _log.info('Configuration cancelled/timed out for ${asset.id.id}'); + return ArrrActivationResultError( + 'Configuration cancelled by user or timed out', + ); + } + + _log.info('Configuration received for ${asset.id.id}'); + } + + _log.info('Starting activation with configuration for ${asset.id.id}'); + return _performActivation(asset, config); + } + + /// Perform the actual activation with configuration + Future _performActivation( + Asset asset, + ZhtlcUserConfig config, + ) async { + const maxAttempts = 5; + var attempt = 0; + + try { + final result = await retry( + () async { + attempt += 1; + _log.info( + 'Starting ARRR activation attempt $attempt for ${asset.id.id}', + ); + + await _cacheActivationStart(asset.id); + + ActivationProgress? lastActivationProgress; + await for (final activationProgress in _sdk.assets.activateAsset( + asset, + )) { + await _cacheActivationProgress(asset.id, activationProgress); + lastActivationProgress = activationProgress; + } + + if (lastActivationProgress?.isSuccess ?? false) { + await _cacheActivationComplete(asset.id); + return ArrrActivationResultSuccess( + Stream.value( + ActivationProgress( + status: 'Activation completed successfully', + progressPercentage: 100, + isComplete: true, + progressDetails: ActivationProgressDetails( + currentStep: ActivationStep.complete, + stepCount: 1, + ), + ), + ), + ); + } + + final errorMessage = + lastActivationProgress?.errorMessage ?? + 'Unknown activation error'; + throw _RetryableZhtlcActivationException(errorMessage); + }, + maxAttempts: maxAttempts, + backoffStrategy: ExponentialBackoff( + initialDelay: const Duration(seconds: 5), + maxDelay: const Duration(seconds: 30), + ), + onRetry: (currentAttempt, error, delay) { + _log.warning( + 'ARRR activation attempt $currentAttempt for ${asset.id.id} failed. ' + 'Retrying in ${delay.inMilliseconds}ms. Error: $error', + ); + }, + ); + + return result; + } catch (e, stackTrace) { + _log.severe( + 'ARRR activation failed after $maxAttempts attempts for ${asset.id.id}', + e, + stackTrace, + ); + await _cacheActivationError(asset.id, e.toString()); + return ArrrActivationResultError(e.toString()); + } + } + + Future _getOrRequestConfiguration(AssetId assetId) async { + final existing = await _configService.getSavedZhtlc(assetId); + if (existing != null) return existing; + + return null; + } + + Future> _getRequiredSettings( + AssetId assetId, + ) async { + return assetId.activationSettings(); + } + + /// Activation status caching for UI display + final Map _activationCache = {}; + final ReadWriteMutex _activationCacheMutex = ReadWriteMutex(); + + Future _cacheActivationStart(AssetId assetId) async { + await _activationCacheMutex.protectWrite(() async { + _activationCache[assetId] = ArrrActivationStatusInProgress( + assetId: assetId, + startTime: DateTime.now(), + ); + }); + } + + Future _cacheActivationProgress( + AssetId assetId, + ActivationProgress progress, + ) async { + await _activationCacheMutex.protectWrite(() async { + final current = _activationCache[assetId]; + if (current is ArrrActivationStatusInProgress) { + _activationCache[assetId] = current.copyWith( + progressPercentage: progress.progressPercentage?.toInt(), + currentStep: progress.progressDetails?.currentStep, + statusMessage: progress.status, + ); + } + }); + } + + Future _cacheActivationComplete(AssetId assetId) async { + await _activationCacheMutex.protectWrite(() async { + _activationCache[assetId] = ArrrActivationStatusCompleted( + assetId: assetId, + completionTime: DateTime.now(), + ); + }); + } + + Future _cacheActivationError( + AssetId assetId, + String errorMessage, + ) async { + await _activationCacheMutex.protectWrite(() async { + _activationCache[assetId] = ArrrActivationStatusError( + assetId: assetId, + errorMessage: errorMessage, + errorTime: DateTime.now(), + ); + }); + } + + // Public method for UI to check activation status + Future getActivationStatus(AssetId assetId) async { + return _activationCacheMutex.protectRead( + () async => _activationCache[assetId], + ); + } + + // Public method for UI to get all cached activation statuses + Future> get activationStatuses async { + return _activationCacheMutex.protectRead( + () async => + Map.unmodifiable(_activationCache), + ); + } + + // Clear cached status when no longer needed + Future clearActivationStatus(AssetId assetId) async { + await _activationCacheMutex.protectWrite( + () async => _activationCache.remove(assetId), + ); + } + + /// Submit configuration for a pending request + /// Called by UI when user provides configuration + Future submitConfiguration( + AssetId assetId, + ZhtlcUserConfig config, + ) async { + if (_isDisposing) { + _log.warning('Ignoring configuration submission - service is disposing'); + return; + } + _log.info('Submitting configuration for ${assetId.id}'); + + // Save configuration to SDK + final completer = _configCompleters[assetId]; + try { + await _configService.saveZhtlcConfig(assetId, config); + _log.info('Configuration saved to SDK for ${assetId.id}'); + } catch (e) { + final error = ArrrActivationResultError( + 'Failed to save configuration: $e', + ); + _log.severe( + 'Failed to save configuration to SDK for ${assetId.id}', + error, + ); + completer?.completeError(error); + return; + } + + if (completer != null && !completer.isCompleted) { + completer.complete(config); + } else { + _log.warning('No pending completer found for ${assetId.id}'); + } + } + + /// Cancel configuration for a pending request + /// Called by UI when user cancels configuration + void cancelConfiguration(AssetId assetId) { + _log.info('Cancelling configuration for ${assetId.id}'); + final completer = _configCompleters[assetId]; + if (completer != null && !completer.isCompleted) { + completer.complete(null); + } else { + _log.warning('No pending completer found for ${assetId.id}'); + } + } + + /// Get diagnostic information about the configuration request system + Map getConfigurationSystemDiagnostics() { + return { + 'hasListeners': _configRequestController.hasListener, + 'isClosed': _configRequestController.isClosed, + 'pendingCompleters': _configCompleters.keys.map((id) => id.id).toList(), + 'handledConfigurations': _configCompleters.length, + }; + } + + /// Test method to verify configuration request system is working + /// This will log diagnostic information + void diagnoseConfigurationSystem() { + final diagnostics = getConfigurationSystemDiagnostics(); + _log.info('Configuration system diagnostics: $diagnostics'); + + if (!_configRequestController.hasListener) { + _log.warning( + 'No listeners detected for configuration requests. ' + 'Make sure ZhtlcConfigurationHandler is in the widget tree.', + ); + } + + if (_configRequestController.isClosed) { + _log.severe('Configuration request controller is closed!'); + } + } + + /// Wait for UI listeners to be ready before emitting configuration requests + /// This ensures the ZhtlcConfigurationHandler is properly initialized + Future _waitForUIListeners(AssetId assetId) async { + const maxWaitTime = Duration(seconds: 10); + const checkInterval = Duration(milliseconds: 100); + final stopwatch = Stopwatch()..start(); + + while (!_configRequestController.hasListener && + stopwatch.elapsed < maxWaitTime) { + _log.info('Waiting for UI listeners to be ready for ${assetId.id}...'); + await Future.delayed(checkInterval); + } + + if (!_configRequestController.hasListener) { + _log.warning( + 'No UI listeners detected after ${maxWaitTime.inSeconds} seconds for ${assetId.id}. ' + 'Make sure ZhtlcConfigurationHandler is in the widget tree.', + ); + } else { + _log.info( + 'UI listeners ready for ${assetId.id} after ${stopwatch.elapsed.inMilliseconds}ms', + ); + } + + stopwatch.stop(); + } + + /// Start listening to authentication state changes + void _startListeningToAuthChanges() { + _authSubscription?.cancel(); + _authSubscription = _sdk.auth.watchCurrentUser().listen( + (user) => unawaited(_handleAuthStateChange(user)), + ); + } + + /// Handle authentication state changes + Future _handleAuthStateChange(KdfUser? user) async { + if (user == null) { + // User signed out - cleanup all active operations + await _cleanupOnSignOut(); + } + } + + /// Clean up all user-specific state when user signs out + Future _cleanupOnSignOut() async { + _log.info('User signed out - cleaning up active ZHTLC activations'); + + // Cancel all pending configuration requests + final pendingAssets = _configCompleters.keys.toList(); + for (final assetId in pendingAssets) { + final completer = _configCompleters[assetId]; + if (completer != null && !completer.isCompleted) { + _log.info('Cancelling pending configuration request for ${assetId.id}'); + completer.complete(null); + } + } + _configCompleters.clear(); + + // Clear activation cache as it's user-specific + var activeAssets = []; + await _activationCacheMutex.protectWrite(() async { + activeAssets = _activationCache.keys.toList(); + for (final assetId in activeAssets) { + _log.info('Clearing activation status for ${assetId.id}'); + } + _activationCache.clear(); + }); + + _log.info( + 'Cleanup completed - cancelled ${pendingAssets.length} pending configs and cleared ${activeAssets.length} activation statuses', + ); + } + + /// Dispose resources + void dispose() { + // Mark as disposing to prevent new operations + _isDisposing = true; + + // Cancel auth subscription first + _authSubscription?.cancel(); + + // Complete any pending configuration requests with a specific error + for (final completer in _configCompleters.values) { + if (!completer.isCompleted) { + completer.completeError(StateError('Service is being disposed')); + } + } + _configCompleters.clear(); + + // Close controller after ensuring all operations are complete + if (!_configRequestController.isClosed) { + _configRequestController.close(); + } + } +} + +class _RetryableZhtlcActivationException implements Exception { + const _RetryableZhtlcActivationException(this.message); + + final String message; + + @override + String toString() => 'RetryableZhtlcActivationException: $message'; +} + +/// Configuration request model for UI handling +class ZhtlcConfigurationRequest { + const ZhtlcConfigurationRequest({ + required this.asset, + required this.requiredSettings, + }); + + final Asset asset; + final List requiredSettings; +} diff --git a/lib/services/arrr_activation/arrr_config.dart b/lib/services/arrr_activation/arrr_config.dart new file mode 100644 index 0000000000..2c70f9dd0b --- /dev/null +++ b/lib/services/arrr_activation/arrr_config.dart @@ -0,0 +1,191 @@ +import 'package:equatable/equatable.dart'; +import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; + +/// ARRR activation result for Future-based API +abstract class ArrrActivationResult { + const ArrrActivationResult(); + + T when({ + required T Function(Stream progress) success, + required T Function( + String coinId, + List requiredSettings, + ) + needsConfiguration, + required T Function(String message) error, + }) { + if (this is ArrrActivationResultSuccess) { + final self = this as ArrrActivationResultSuccess; + return success(self.progress); + } else if (this is ArrrActivationResultNeedsConfig) { + final self = this as ArrrActivationResultNeedsConfig; + return needsConfiguration(self.coinId, self.requiredSettings); + } else if (this is ArrrActivationResultError) { + final self = this as ArrrActivationResultError; + return error(self.message); + } + throw StateError('Unknown ArrrActivationResult type: $runtimeType'); + } +} + +class ArrrActivationResultSuccess extends ArrrActivationResult { + const ArrrActivationResultSuccess(this.progress); + + final Stream progress; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is ArrrActivationResultSuccess && other.progress == progress; + } + + @override + int get hashCode => progress.hashCode; +} + +class ArrrActivationResultNeedsConfig extends ArrrActivationResult { + const ArrrActivationResultNeedsConfig({ + required this.coinId, + required this.requiredSettings, + }); + + final String coinId; + final List requiredSettings; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is ArrrActivationResultNeedsConfig && + other.coinId == coinId && + other.requiredSettings == requiredSettings; + } + + @override + int get hashCode => Object.hash(coinId, requiredSettings); +} + +class ArrrActivationResultError extends ArrrActivationResult { + const ArrrActivationResultError(this.message); + + final String message; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is ArrrActivationResultError && other.message == message; + } + + @override + int get hashCode => message.hashCode; +} + +/// ARRR activation status for UI caching +abstract class ArrrActivationStatus extends Equatable { + const ArrrActivationStatus(); + + T when({ + required T Function( + AssetId assetId, + DateTime startTime, + int? progressPercentage, + ActivationStep? currentStep, + String? statusMessage, + ) + inProgress, + required T Function(AssetId assetId, DateTime completionTime) completed, + required T Function( + AssetId assetId, + String errorMessage, + DateTime errorTime, + ) + error, + }) { + if (this is ArrrActivationStatusInProgress) { + final self = this as ArrrActivationStatusInProgress; + return inProgress( + self.assetId, + self.startTime, + self.progressPercentage, + self.currentStep, + self.statusMessage, + ); + } else if (this is ArrrActivationStatusCompleted) { + final self = this as ArrrActivationStatusCompleted; + return completed(self.assetId, self.completionTime); + } else if (this is ArrrActivationStatusError) { + final self = this as ArrrActivationStatusError; + return error(self.assetId, self.errorMessage, self.errorTime); + } + throw StateError('Unknown ArrrActivationStatus type: $runtimeType'); + } +} + +class ArrrActivationStatusInProgress extends ArrrActivationStatus { + const ArrrActivationStatusInProgress({ + required this.assetId, + required this.startTime, + this.progressPercentage, + this.currentStep, + this.statusMessage, + }); + + final AssetId assetId; + final DateTime startTime; + final int? progressPercentage; + final ActivationStep? currentStep; + final String? statusMessage; + + ArrrActivationStatusInProgress copyWith({ + AssetId? assetId, + DateTime? startTime, + int? progressPercentage, + ActivationStep? currentStep, + String? statusMessage, + }) { + return ArrrActivationStatusInProgress( + assetId: assetId ?? this.assetId, + startTime: startTime ?? this.startTime, + progressPercentage: progressPercentage ?? this.progressPercentage, + currentStep: currentStep ?? this.currentStep, + statusMessage: statusMessage ?? this.statusMessage, + ); + } + + @override + List get props => [ + assetId, + startTime, + progressPercentage, + currentStep, + statusMessage, + ]; +} + +class ArrrActivationStatusCompleted extends ArrrActivationStatus { + const ArrrActivationStatusCompleted({ + required this.assetId, + required this.completionTime, + }); + + final AssetId assetId; + final DateTime completionTime; + + @override + List get props => [assetId, completionTime]; +} + +class ArrrActivationStatusError extends ArrrActivationStatus { + const ArrrActivationStatusError({ + required this.assetId, + required this.errorMessage, + required this.errorTime, + }); + + final AssetId assetId; + final String errorMessage; + final DateTime errorTime; + + @override + List get props => [assetId, errorMessage, errorTime]; +} diff --git a/lib/shared/utils/utils.dart b/lib/shared/utils/utils.dart index c25a668ff0..6fff9f4bb8 100644 --- a/lib/shared/utils/utils.dart +++ b/lib/shared/utils/utils.dart @@ -392,6 +392,7 @@ final Map _abbr2TickerCache = {}; Color getProtocolColor(CoinType type) { switch (type) { + case CoinType.zhtlc: case CoinType.utxo: return const Color.fromRGBO(233, 152, 60, 1); case CoinType.erc20: @@ -455,6 +456,7 @@ bool hasTxHistorySupport(Coin coin) { case CoinType.hco20: case CoinType.plg20: case CoinType.slp: + case CoinType.zhtlc: return true; } } @@ -471,6 +473,7 @@ String getNativeExplorerUrlByCoin(Coin coin, String? address) { case CoinType.tendermintToken: return '${coin.explorerUrl}account/$coinAddress'; + case CoinType.zhtlc: case CoinType.utxo: case CoinType.smartChain: case CoinType.erc20: diff --git a/lib/views/bridge/bridge_page.dart b/lib/views/bridge/bridge_page.dart index 6f8b54fe8c..32456a02d8 100644 --- a/lib/views/bridge/bridge_page.dart +++ b/lib/views/bridge/bridge_page.dart @@ -16,6 +16,7 @@ import 'package:web_dex/views/common/pages/page_layout.dart'; import 'package:web_dex/views/dex/entities_list/history/history_list.dart'; import 'package:web_dex/views/dex/entities_list/in_progress/in_progress_list.dart'; import 'package:web_dex/views/dex/entity_details/trading_details.dart'; +import 'package:web_dex/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_handler.dart'; class BridgePage extends StatefulWidget { const BridgePage() : super(key: const Key('bridge-page')); @@ -50,17 +51,21 @@ class _BridgePageState extends State with TickerProviderStateMixin { }); } }, - child: Builder(builder: (context) { - final page = _showSwap ? _buildTradingDetails() : _buildBridgePage(); - return page; - }), + child: ZhtlcConfigurationHandler( + child: Builder( + builder: (context) { + final page = _showSwap + ? _buildTradingDetails() + : _buildBridgePage(); + return page; + }, + ), + ), ); } Widget _buildTradingDetails() { - return TradingDetails( - uuid: routingState.bridgeState.uuid, - ); + return TradingDetails(uuid: routingState.bridgeState.uuid); } Widget _buildBridgePage() { @@ -78,8 +83,9 @@ class _BridgePageState extends State with TickerProviderStateMixin { crossAxisAlignment: CrossAxisAlignment.center, children: [ ConstrainedBox( - constraints: - BoxConstraints(maxWidth: theme.custom.dexFormWidth), + constraints: BoxConstraints( + maxWidth: theme.custom.dexFormWidth, + ), child: HiddenWithoutWallet( child: BridgeTabBar( currentTabIndex: _activeTabIndex, @@ -91,11 +97,7 @@ class _BridgePageState extends State with TickerProviderStateMixin { padding: EdgeInsets.only(top: 12.0), child: ClockWarningBanner(), ), - Flexible( - child: _TabContent( - activeTabIndex: _activeTabIndex, - ), - ), + Flexible(child: _TabContent(activeTabIndex: _activeTabIndex)), ], ), ), @@ -128,7 +130,7 @@ class _BridgePageState extends State with TickerProviderStateMixin { class _TabContent extends StatelessWidget { final int _activeTabIndex; const _TabContent({required int activeTabIndex}) - : _activeTabIndex = activeTabIndex; + : _activeTabIndex = activeTabIndex; @override Widget build(BuildContext context) { @@ -137,7 +139,9 @@ class _TabContent extends StatelessWidget { Padding( padding: const EdgeInsets.only(top: 20), child: InProgressList( - filter: _bridgeSwapsFilter, onItemClick: _onSwapItemClick), + filter: _bridgeSwapsFilter, + onItemClick: _onSwapItemClick, + ), ), Padding( padding: const EdgeInsets.only(top: 20), diff --git a/lib/views/dex/dex_page.dart b/lib/views/dex/dex_page.dart index 0224ea28cb..5acf92bf94 100644 --- a/lib/views/dex/dex_page.dart +++ b/lib/views/dex/dex_page.dart @@ -17,6 +17,7 @@ import 'package:web_dex/views/common/pages/page_layout.dart'; import 'package:web_dex/views/dex/dex_tab_bar.dart'; import 'package:web_dex/views/dex/entities_list/dex_list_wrapper.dart'; import 'package:web_dex/views/dex/entity_details/trading_details.dart'; +import 'package:web_dex/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_handler.dart'; class DexPage extends StatefulWidget { const DexPage({super.key}); @@ -42,8 +43,9 @@ class _DexPageState extends State { @override Widget build(BuildContext context) { - final tradingEntitiesBloc = - RepositoryProvider.of(context); + final tradingEntitiesBloc = RepositoryProvider.of( + context, + ); final coinsRepository = RepositoryProvider.of(context); final myOrdersService = RepositoryProvider.of(context); @@ -66,13 +68,11 @@ class _DexPageState extends State { ? TradingDetails(uuid: routingState.dexState.uuid) : _DexContent(), ); - return pageContent; + return ZhtlcConfigurationHandler(child: pageContent); } void _onRouteChange() { - setState( - () => isTradingDetails = routingState.dexState.isTradingDetails, - ); + setState(() => isTradingDetails = routingState.dexState.isTradingDetails); } } diff --git a/lib/views/dex/orderbook/orderbook_error_message.dart b/lib/views/dex/orderbook/orderbook_error_message.dart index ac13b32973..e7e9912a3d 100644 --- a/lib/views/dex/orderbook/orderbook_error_message.dart +++ b/lib/views/dex/orderbook/orderbook_error_message.dart @@ -1,17 +1,16 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; -import 'package:web_dex/mm2/mm2_api/rpc/orderbook/orderbook_response.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; class OrderbookErrorMessage extends StatefulWidget { const OrderbookErrorMessage( - this.response, { + this.errorMessage, { Key? key, required this.onReloadClick, }) : super(key: key); - final OrderbookResponse response; + final String errorMessage; final VoidCallback onReloadClick; @override @@ -23,8 +22,8 @@ class _OrderbookErrorMessageState extends State { @override Widget build(BuildContext context) { - final String? error = widget.response.error; - if (error == null) return const SizedBox.shrink(); + final String error = widget.errorMessage; + if (error.isEmpty) return const SizedBox.shrink(); return Center( child: Column( @@ -45,25 +44,24 @@ class _OrderbookErrorMessageState extends State { ), const SizedBox(width: 8), InkWell( - onTap: () => setState(() => _isExpanded = !_isExpanded), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - _isExpanded - ? LocaleKeys.close.tr() - : LocaleKeys.details.tr(), - style: const TextStyle(fontSize: 12), - ), - Icon( - _isExpanded - ? Icons.arrow_drop_up - : Icons.arrow_drop_down, - size: 16, - color: Theme.of(context).textTheme.bodyMedium?.color, - ), - ], - )), + onTap: () => setState(() => _isExpanded = !_isExpanded), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + _isExpanded + ? LocaleKeys.close.tr() + : LocaleKeys.details.tr(), + style: const TextStyle(fontSize: 12), + ), + Icon( + _isExpanded ? Icons.arrow_drop_up : Icons.arrow_drop_down, + size: 16, + color: Theme.of(context).textTheme.bodyMedium?.color, + ), + ], + ), + ), ], ), if (_isExpanded) diff --git a/lib/views/dex/orderbook/orderbook_view.dart b/lib/views/dex/orderbook/orderbook_view.dart index e9ae5ddba8..50fe0127c4 100644 --- a/lib/views/dex/orderbook/orderbook_view.dart +++ b/lib/views/dex/orderbook/orderbook_view.dart @@ -5,7 +5,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/blocs/orderbook_bloc.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; -import 'package:web_dex/mm2/mm2_api/rpc/orderbook/orderbook_response.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/orderbook/order.dart'; import 'package:web_dex/model/orderbook/orderbook.dart'; @@ -66,30 +65,33 @@ class _OrderbookViewState extends State { @override Widget build(BuildContext context) { - return StreamBuilder( + return StreamBuilder( initialData: _model.response, stream: _model.outResponse, builder: (context, snapshot) { if (!_model.isComplete) return const SizedBox.shrink(); - final OrderbookResponse? response = snapshot.data; + final OrderbookResult? result = snapshot.data; - if (response == null) { + if (result == null) { return const Center(child: UiSpinner()); } - if (response.error != null) { + if (result.hasError) { return OrderbookErrorMessage( - response, + result.error ?? LocaleKeys.orderBookFailedLoadError.tr(), onReloadClick: _model.reload, ); } - final Orderbook? orderbook = response.result; - if (orderbook == null) { - return Center( - child: Text(LocaleKeys.orderBookEmpty.tr()), - ); + final response = result.response; + if (response == null) { + return const Center(child: UiSpinner()); + } + + final Orderbook orderbook = Orderbook.fromSdkResponse(response); + if (orderbook.asks.isEmpty && orderbook.bids.isEmpty) { + return Center(child: Text(LocaleKeys.orderBookEmpty.tr())); } return GradientBorder( @@ -97,8 +99,10 @@ class _OrderbookViewState extends State { gradient: dexPageColors.formPlateGradient, child: Container( constraints: BoxConstraints(maxWidth: theme.custom.dexFormWidth), - padding: - const EdgeInsets.symmetric(horizontal: 16.0, vertical: 16.0), + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 16.0, + ), child: Column( mainAxisSize: MainAxisSize.max, crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/views/dex/simple/form/tables/coins_table/coins_table.dart b/lib/views/dex/simple/form/tables/coins_table/coins_table.dart index c8140c10e4..04c6d392e1 100644 --- a/lib/views/dex/simple/form/tables/coins_table/coins_table.dart +++ b/lib/views/dex/simple/form/tables/coins_table/coins_table.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:komodo_ui/komodo_ui.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/views/dex/common/front_plate.dart'; import 'package:web_dex/views/dex/simple/form/tables/coins_table/coins_table_content.dart'; @@ -22,6 +23,13 @@ class CoinsTable extends StatefulWidget { class _CoinsTableState extends State { String? _searchTerm; + late final Debouncer _searchDebouncer; + + @override + void initState() { + super.initState(); + _searchDebouncer = Debouncer(duration: const Duration(milliseconds: 200)); + } @override Widget build(BuildContext context) { @@ -38,8 +46,12 @@ class _CoinsTableState extends State { child: TableSearchField( height: 30, onChanged: (String value) { - if (_searchTerm == value) return; - setState(() => _searchTerm = value); + final nextValue = value; + _searchDebouncer.run(() { + if (!mounted) return; + if (_searchTerm == nextValue) return; + setState(() => _searchTerm = nextValue); + }); }, ), ), @@ -54,4 +66,10 @@ class _CoinsTableState extends State { ), ); } + + @override + void dispose() { + _searchDebouncer.dispose(); + super.dispose(); + } } diff --git a/lib/views/dex/simple/form/tables/table_utils.dart b/lib/views/dex/simple/form/tables/table_utils.dart index 5b4878b672..d827d6373b 100644 --- a/lib/views/dex/simple/form/tables/table_utils.dart +++ b/lib/views/dex/simple/form/tables/table_utils.dart @@ -1,14 +1,13 @@ +import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; -import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart' show AssetId; import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; import 'package:web_dex/mm2/mm2_api/rpc/best_orders/best_orders.dart'; import 'package:web_dex/model/authorize_mode.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/coin_utils.dart'; -import 'package:web_dex/shared/utils/balances_formatter.dart'; import 'package:web_dex/bloc/trading_status/trading_status_bloc.dart'; List prepareCoinsForTable( @@ -17,13 +16,15 @@ List prepareCoinsForTable( String? searchString, { bool testCoinsEnabled = true, }) { - final authBloc = RepositoryProvider.of(context); - coins = List.from(coins); - if (!testCoinsEnabled) coins = removeTestCoins(coins); + final sdk = RepositoryProvider.of(context); + + coins = List.of(coins); + if (!testCoinsEnabled) { + coins = removeTestCoins(coins); + } coins = removeWalletOnly(coins); coins = removeDisallowedCoins(context, coins); - coins = removeSuspended(coins, authBloc.state.isSignedIn); - coins = sortByPriorityAndBalance(coins, GetIt.I()); + coins = sortByPriorityAndBalance(coins, sdk); coins = filterCoinsByPhrase(coins, searchString ?? '').toList(); return coins; } @@ -32,35 +33,58 @@ List prepareOrdersForTable( BuildContext context, Map>? orders, String? searchString, - AuthorizeMode mode, { + AuthorizeMode _mode, { bool testCoinsEnabled = true, + Coin? Function(String)? coinLookup, }) { if (orders == null) return []; - final List sorted = _sortBestOrders(context, orders); - if (sorted.isEmpty) return []; + final caches = buildOrderCoinCaches(context, orders, coinLookup: coinLookup); - if (!testCoinsEnabled) { - removeTestCoinOrders(sorted, context); - if (sorted.isEmpty) return []; + final ordersByAssetId = caches.ordersByAssetId; + final coinsByAssetId = caches.coinsByAssetId; + final assetIdByAbbr = caches.assetIdByAbbr; + + final List sorted = _sortBestOrders( + ordersByAssetId, + coinsByAssetId, + ); + if (sorted.isEmpty) { + return []; } - removeSuspendedCoinOrders(sorted, mode, context); - if (sorted.isEmpty) return []; + if (!testCoinsEnabled) { + removeTestCoinOrders( + sorted, + ordersByAssetId, + coinsByAssetId, + assetIdByAbbr, + ); + if (sorted.isEmpty) { + return []; + } + } - removeWalletOnlyCoinOrders(sorted, context); - if (sorted.isEmpty) return []; + removeWalletOnlyCoinOrders( + sorted, + ordersByAssetId, + coinsByAssetId, + assetIdByAbbr, + ); + if (sorted.isEmpty) { + return []; + } removeDisallowedCoinOrders(sorted, context); if (sorted.isEmpty) return []; - final String? filter = searchString?.toLowerCase(); if (filter == null || filter.isEmpty) { return sorted; } - final coinsRepository = RepositoryProvider.of(context); final List filtered = sorted.where((order) { - final Coin? coin = coinsRepository.getCoin(order.coin); + final AssetId? assetId = assetIdByAbbr[order.coin]; + if (assetId == null) return false; + final Coin? coin = coinsByAssetId[assetId]; if (coin == null) return false; return compareCoinByPhrase(coin, filter); }).toList(); @@ -140,67 +164,115 @@ void removeDisallowedCoinOrders(List orders, BuildContext context) { }); } -List _sortBestOrders( +({ + Map ordersByAssetId, + Map coinsByAssetId, + Map assetIdByAbbr, +}) +buildOrderCoinCaches( BuildContext context, - Map> unsorted, -) { - if (unsorted.isEmpty) return []; - - final coinsRepository = RepositoryProvider.of(context); - final List sorted = []; - unsorted.forEach((ticker, list) { - if (coinsRepository.getCoin(list[0].coin) == null) return; - sorted.add(list[0]); - }); - - sorted.sort((a, b) { - final Coin? coinA = coinsRepository.getCoin(a.coin); - final Coin? coinB = coinsRepository.getCoin(b.coin); - if (coinA == null || coinB == null) return 0; + Map> orders, { + Coin? Function(String)? coinLookup, +}) { + final Coin? Function(String) resolveCoin = + coinLookup ?? RepositoryProvider.of(context).getCoin; - final double fiatPriceA = getFiatAmount(coinA, a.price); - final double fiatPriceB = getFiatAmount(coinB, b.price); + final ordersByAssetId = {}; + final coinsByAssetId = {}; + final assetIdByAbbr = {}; - if (fiatPriceA > fiatPriceB) return -1; - if (fiatPriceA < fiatPriceB) return 1; + orders.forEach((_, list) { + if (list.isEmpty) return; + final BestOrder order = list[0]; + final Coin? coin = resolveCoin(order.coin); + if (coin == null) return; - return coinA.abbr.compareTo(coinB.abbr); + final AssetId assetId = coin.assetId; + ordersByAssetId[assetId] = order; + coinsByAssetId[assetId] = coin; + assetIdByAbbr[coin.abbr] = assetId; }); - return sorted; + return ( + ordersByAssetId: ordersByAssetId, + coinsByAssetId: coinsByAssetId, + assetIdByAbbr: assetIdByAbbr, + ); } -void removeSuspendedCoinOrders( - List orders, - AuthorizeMode authorizeMode, - BuildContext context, +List _sortBestOrders( + Map ordersByAssetId, + Map coinsByAssetId, ) { - if (authorizeMode == AuthorizeMode.noLogin) return; - final coinsRepository = RepositoryProvider.of(context); - orders.removeWhere((BestOrder order) { - final Coin? coin = coinsRepository.getCoin(order.coin); - if (coin == null) return true; + if (ordersByAssetId.isEmpty) return []; + final entries = + <({AssetId assetId, BestOrder order, Coin coin, double fiatPrice})>[]; + + ordersByAssetId.forEach((assetId, order) { + final Coin? coin = coinsByAssetId[assetId]; + if (coin == null) return; + + final Decimal? usdPrice = coin.usdPrice?.price; + final double fiatPrice = + order.price.toDouble() * (usdPrice?.toDouble() ?? 0.0); + entries.add(( + assetId: assetId, + order: order, + coin: coin, + fiatPrice: fiatPrice, + )); + }); - return coin.isSuspended; + entries.sort((a, b) { + final int fiatComparison = b.fiatPrice.compareTo(a.fiatPrice); + if (fiatComparison != 0) return fiatComparison; + return a.coin.abbr.compareTo(b.coin.abbr); }); + + final result = entries.map((entry) => entry.order).toList(); + return result; } -void removeWalletOnlyCoinOrders(List orders, BuildContext context) { - final coinsRepository = RepositoryProvider.of(context); +void removeWalletOnlyCoinOrders( + List orders, + Map ordersByAssetId, + Map coinsByAssetId, + Map assetIdByAbbr, +) { orders.removeWhere((BestOrder order) { - final Coin? coin = coinsRepository.getCoin(order.coin); + final AssetId? assetId = assetIdByAbbr[order.coin]; + if (assetId == null) return true; + final Coin? coin = coinsByAssetId[assetId]; if (coin == null) return true; - return coin.walletOnly; + final bool shouldRemove = coin.walletOnly; + if (shouldRemove) { + ordersByAssetId.remove(assetId); + coinsByAssetId.remove(assetId); + assetIdByAbbr.remove(order.coin); + } + return shouldRemove; }); } -void removeTestCoinOrders(List orders, BuildContext context) { - final coinsRepository = RepositoryProvider.of(context); +void removeTestCoinOrders( + List orders, + Map ordersByAssetId, + Map coinsByAssetId, + Map assetIdByAbbr, +) { orders.removeWhere((BestOrder order) { - final Coin? coin = coinsRepository.getCoin(order.coin); + final AssetId? assetId = assetIdByAbbr[order.coin]; + if (assetId == null) return true; + final Coin? coin = coinsByAssetId[assetId]; if (coin == null) return true; - return coin.isTestCoin; + final bool shouldRemove = coin.isTestCoin; + if (shouldRemove) { + ordersByAssetId.remove(assetId); + coinsByAssetId.remove(assetId); + assetIdByAbbr.remove(order.coin); + } + return shouldRemove; }); } diff --git a/lib/views/market_maker_bot/update_interval_dropdown.dart b/lib/views/market_maker_bot/update_interval_dropdown.dart index ee5e3c99f5..84232cb699 100644 --- a/lib/views/market_maker_bot/update_interval_dropdown.dart +++ b/lib/views/market_maker_bot/update_interval_dropdown.dart @@ -29,7 +29,6 @@ class UpdateIntervalDropdown extends StatelessWidget { child: DropdownButtonFormField( value: interval, onChanged: onChanged, - focusColor: Colors.transparent, items: TradeBotUpdateInterval.values .map( (interval) => DropdownMenuItem( diff --git a/lib/views/wallet/coin_details/transactions/transaction_details.dart b/lib/views/wallet/coin_details/transactions/transaction_details.dart index 103333a4ac..43ecc5fda2 100644 --- a/lib/views/wallet/coin_details/transactions/transaction_details.dart +++ b/lib/views/wallet/coin_details/transactions/transaction_details.dart @@ -4,7 +4,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:komodo_ui/komodo_ui.dart'; -import 'package:komodo_ui/utils.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; import 'package:web_dex/common/screen.dart'; @@ -116,13 +115,17 @@ class TransactionDetails extends StatelessWidget { _buildSimpleData( context, title: LocaleKeys.from.tr(), - value: transaction.from.first, + value: transaction.from.isEmpty + ? LocaleKeys.zhtlcShieldedAddress.tr() + : transaction.from.first, isCopied: true, ), _buildSimpleData( context, title: LocaleKeys.to.tr(), - value: transaction.to.first, + value: transaction.to.isEmpty + ? LocaleKeys.zhtlcShieldedAddress.tr() + : transaction.to.first, isCopied: true, ), SizedBox(height: 16), diff --git a/lib/views/wallet/coin_details/withdraw_form/withdraw_form.dart b/lib/views/wallet/coin_details/withdraw_form/withdraw_form.dart index e5f809adc3..0f05b5dd57 100644 --- a/lib/views/wallet/coin_details/withdraw_form/withdraw_form.dart +++ b/lib/views/wallet/coin_details/withdraw_form/withdraw_form.dart @@ -16,6 +16,7 @@ import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; import 'package:web_dex/model/text_error.dart'; import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/shared/widgets/copied_text.dart' show CopiedTextV2; import 'package:web_dex/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fill_form_memo.dart'; import 'package:web_dex/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fields.dart'; import 'package:web_dex/views/wallet/coin_details/withdraw_form/widgets/withdraw_form_header.dart'; @@ -251,10 +252,13 @@ class PreviewWithdrawButton extends StatelessWidget { child: UiPrimaryButton( onPressed: onPressed, child: isSending - ? const SizedBox( + ? SizedBox( width: 20, height: 20, - child: CircularProgressIndicator(strokeWidth: 2), + child: CircularProgressIndicator( + strokeWidth: 2, + color: Theme.of(context).colorScheme.onPrimary, + ), ) : Text(LocaleKeys.withdrawPreview.tr()), ), @@ -262,6 +266,40 @@ class PreviewWithdrawButton extends StatelessWidget { } } +class ZhtlcPreviewDelayNote extends StatelessWidget { + const ZhtlcPreviewDelayNote({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final backgroundColor = theme.colorScheme.secondaryContainer; + final foregroundColor = theme.colorScheme.onSecondaryContainer; + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, color: foregroundColor), + const SizedBox(width: 12), + Expanded( + child: Text( + LocaleKeys.withdrawPreviewZhtlcNote.tr(), + style: theme.textTheme.bodyMedium?.copyWith( + color: foregroundColor, + ), + ), + ), + ], + ), + ); + } +} + class WithdrawPreviewDetails extends StatelessWidget { final WithdrawalPreview preview; @@ -275,23 +313,51 @@ class WithdrawPreviewDetails extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildRow( + _buildTextRow( LocaleKeys.amount.tr(), preview.balanceChanges.netChange.toString(), ), const SizedBox(height: 8), - _buildRow(LocaleKeys.fee.tr(), preview.fee.formatTotal()), - // Add more preview details as needed + _buildTextRow(LocaleKeys.fee.tr(), preview.fee.formatTotal()), + const SizedBox(height: 8), + _buildRow( + LocaleKeys.recipientAddress.tr(), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + for (final recipient in preview.to) + CopiedTextV2(copiedValue: recipient, fontSize: 14), + ], + ), + ), + if (preview.memo != null) ...[ + const SizedBox(height: 8), + _buildTextRow(LocaleKeys.memo.tr(), preview.memo!), + ], ], ), ), ); } - Widget _buildRow(String label, String value) { + Widget _buildTextRow(String label, String value) { + return _buildRow( + label, + AutoScrollText(text: value, textAlign: TextAlign.right), + ); + } + + Widget _buildRow(String label, Widget value) { return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [Text(label), Text(value)], + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label), + const SizedBox(width: 12), + Expanded( + child: Align(alignment: Alignment.centerRight, child: value), + ), + ], ); } } @@ -469,6 +535,11 @@ class WithdrawFormFillSection extends StatelessWidget { }, isSending: state.isSending, ), + if (state.asset.id.subClass == CoinSubClass.zhtlc && + state.isSending) ...[ + const SizedBox(height: 12), + const ZhtlcPreviewDelayNote(), + ], ], ); }, diff --git a/lib/views/wallet/coins_manager/coins_manager_controls.dart b/lib/views/wallet/coins_manager/coins_manager_controls.dart index aa00abcc52..0259da5a36 100644 --- a/lib/views/wallet/coins_manager/coins_manager_controls.dart +++ b/lib/views/wallet/coins_manager/coins_manager_controls.dart @@ -3,6 +3,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui/komodo_ui.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/bloc/coins_manager/coins_manager_bloc.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; @@ -10,14 +11,32 @@ import 'package:web_dex/views/custom_token_import/custom_token_import_button.dar import 'package:web_dex/views/wallet/coins_manager/coins_manager_filters_dropdown.dart'; import 'package:web_dex/views/wallet/coins_manager/coins_manager_select_all_button.dart'; -class CoinsManagerFilters extends StatelessWidget { - const CoinsManagerFilters({Key? key, required this.isMobile}) - : super(key: key); +class CoinsManagerFilters extends StatefulWidget { + const CoinsManagerFilters({super.key, required this.isMobile}); final bool isMobile; + @override + State createState() => _CoinsManagerFiltersState(); +} + +class _CoinsManagerFiltersState extends State { + late final Debouncer _debouncer; + + @override + void initState() { + super.initState(); + _debouncer = Debouncer(duration: const Duration(milliseconds: 100)); + } + + @override + void dispose() { + _debouncer.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - if (isMobile) { + if (widget.isMobile) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -70,7 +89,7 @@ class CoinsManagerFilters extends StatelessWidget { Widget _buildSearchField(BuildContext context) { return UiTextFormField( key: const Key('coins-manager-search-field'), - fillColor: isMobile + fillColor: widget.isMobile ? theme.custom.coinsManagerTheme.searchFieldMobileBackgroundColor : null, autocorrect: false, @@ -80,13 +99,14 @@ class CoinsManagerFilters extends StatelessWidget { prefixIcon: const Icon(Icons.search, size: 18), inputFormatters: [LengthLimitingTextInputFormatter(40)], hintText: LocaleKeys.searchAssets.tr(), - hintTextStyle: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - ), - onChanged: (String? text) => context - .read() - .add(CoinsManagerSearchUpdate(text: text ?? '')), + hintTextStyle: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500), + onChanged: (String? text) => _debouncer.run(() { + if (mounted) { + context.read().add( + CoinsManagerSearchUpdate(text: text ?? ''), + ); + } + }), ); } } diff --git a/lib/views/wallet/coins_manager/coins_manager_page.dart b/lib/views/wallet/coins_manager/coins_manager_page.dart index ad26acfbd7..00810f0475 100644 --- a/lib/views/wallet/coins_manager/coins_manager_page.dart +++ b/lib/views/wallet/coins_manager/coins_manager_page.dart @@ -3,20 +3,20 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; -import 'package:web_dex/bloc/coins_bloc/coins_bloc.dart'; -import 'package:web_dex/bloc/coins_manager/coins_manager_bloc.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/router/state/wallet_state.dart'; import 'package:web_dex/views/common/page_header/page_header.dart'; import 'package:web_dex/views/common/pages/page_layout.dart'; import 'package:web_dex/views/wallet/coins_manager/coins_manager_list_wrapper.dart'; +import 'package:web_dex/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_handler.dart' + show ZhtlcConfigurationHandler; class CoinsManagerPage extends StatelessWidget { const CoinsManagerPage({ - Key? key, + super.key, required this.action, required this.closePage, - }) : super(key: key); + }); final CoinsManagerAction action; final void Function() closePage; @@ -31,27 +31,29 @@ class CoinsManagerPage extends StatelessWidget { ? LocaleKeys.addAssets.tr() : LocaleKeys.removeAssets.tr(); - return PageLayout( - header: PageHeader( - title: title, - backText: LocaleKeys.backToWallet.tr(), - onBackButtonPressed: closePage, - ), - content: Flexible( - child: Padding( - padding: const EdgeInsets.only(top: 20.0), - child: BlocBuilder( - builder: (context, state) { - if (!state.isSignedIn) { - return const Center( - child: Padding( - padding: EdgeInsets.fromLTRB(0, 100, 0, 100), - child: UiSpinner(), - ), - ); - } - return const CoinsManagerListWrapper(); - }, + return ZhtlcConfigurationHandler( + child: PageLayout( + header: PageHeader( + title: title, + backText: LocaleKeys.backToWallet.tr(), + onBackButtonPressed: closePage, + ), + content: Flexible( + child: Padding( + padding: const EdgeInsets.only(top: 20.0), + child: BlocBuilder( + builder: (context, state) { + if (!state.isSignedIn) { + return const Center( + child: Padding( + padding: EdgeInsets.fromLTRB(0, 100, 0, 100), + child: UiSpinner(), + ), + ); + } + return const CoinsManagerListWrapper(); + }, + ), ), ), ), diff --git a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart new file mode 100644 index 0000000000..605bab4f7b --- /dev/null +++ b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart @@ -0,0 +1,270 @@ +import 'dart:async'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart' + show ActivationStep, AssetId; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart' show LocaleKeys; +import 'package:web_dex/services/arrr_activation/arrr_activation_service.dart'; +import 'package:web_dex/services/arrr_activation/arrr_config.dart'; + +/// Status bar widget to display ZHTLC activation progress for multiple coins +class ZhtlcActivationStatusBar extends StatefulWidget { + const ZhtlcActivationStatusBar({super.key, required this.activationService}); + + final ArrrActivationService activationService; + + @override + State createState() => + _ZhtlcActivationStatusBarState(); +} + +class _ZhtlcActivationStatusBarState extends State { + Timer? _refreshTimer; + Map _cachedStatuses = {}; + StreamSubscription? _authSubscription; + + @override + void initState() { + super.initState(); + _startPeriodicRefresh(); + _subscribeToAuthChanges(); + } + + @override + void dispose() { + _refreshTimer?.cancel(); + _authSubscription?.cancel(); + super.dispose(); + } + + void _subscribeToAuthChanges() { + _authSubscription = context.read().stream.listen((state) { + if (state.currentUser == null) { + unawaited(_handleSignedOut()); + } + }); + } + + void _startPeriodicRefresh() { + unawaited(_refreshStatuses()); + _refreshTimer = Timer.periodic(const Duration(seconds: 1), (_) { + unawaited(_refreshStatuses()); + }); + } + + Future _refreshStatuses() async { + final newStatuses = await widget.activationService.activationStatuses; + + if (!mounted) { + return; + } + + setState(() { + _cachedStatuses = newStatuses; + }); + } + + Future _handleSignedOut() async { + if (!mounted) { + _cachedStatuses = {}; + return; + } + + final assetIds = _cachedStatuses.keys.toList(); + for (final assetId in assetIds) { + await widget.activationService.clearActivationStatus(assetId); + } + + if (!mounted) { + _cachedStatuses = {}; + return; + } + + setState(() { + _cachedStatuses = {}; + }); + } + + @override + Widget build(BuildContext context) { + // Filter out completed or error statuses older than 5 seconds + final activeStatuses = _cachedStatuses.entries.where((entry) { + final status = entry.value; + return status.when( + inProgress: + ( + assetId, + startTime, + progressPercentage, + currentStep, + statusMessage, + ) => true, + completed: (coinId, completionTime) => + DateTime.now().difference(completionTime).inSeconds < 5, + error: (coinId, errorMessage, errorTime) => + DateTime.now().difference(errorTime).inSeconds < 5, + ); + }).toList(); + + if (activeStatuses.isEmpty) { + return const SizedBox.shrink(); + } + + final coinCount = activeStatuses.length; + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2), + ), + ), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 12.0), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + Row( + children: [ + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Theme.of(context).colorScheme.primary, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: AutoScrollText( + text: LocaleKeys.zhtlcActivating.plural(coinCount), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + Row( + children: [ + const SizedBox(width: 26), + Expanded( + child: AutoScrollText( + text: LocaleKeys.zhtlcActivationWarning.tr(), + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + const SizedBox(height: 8), + Column( + children: activeStatuses.map((entry) { + final status = entry.value; + return Padding( + padding: const EdgeInsets.only(top: 4.0), + child: status.when( + completed: (_, __) => const SizedBox.shrink(), + error: (assetId, errorMessage, errorTime) => Row( + children: [ + Icon( + Icons.error_outline, + size: 14, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(width: 12), + Expanded( + child: AutoScrollText( + text: errorMessage, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + ), + ], + ), + inProgress: + ( + assetId, + startTime, + progressPercentage, + currentStep, + statusMessage, + ) { + return _ActivationStatusDetails( + assetId: assetId, + progressPercentage: + progressPercentage?.toDouble() ?? 0, + currentStep: currentStep!, + statusMessage: + statusMessage ?? LocaleKeys.inProgress.tr(), + ); + }, + ), + ); + }).toList(), + ), + ], + ), + ), + ), + ); + } +} + +class _ActivationStatusDetails extends StatelessWidget { + const _ActivationStatusDetails({ + required this.assetId, + required this.progressPercentage, + required this.currentStep, + required this.statusMessage, + }); + + final AssetId assetId; + final double progressPercentage; + final ActivationStep currentStep; + final String statusMessage; + + @override + Widget build(BuildContext context) { + final statusDetailsText = + '${assetId.id}: $statusMessage ' + '(${progressPercentage.toStringAsFixed(0)}%)'; + + return Padding( + padding: const EdgeInsets.only(left: 24.0), + child: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Expanded( + child: AutoScrollText( + text: statusDetailsText, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).textTheme.bodySmall?.color, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart new file mode 100644 index 0000000000..881c3e0f4f --- /dev/null +++ b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart @@ -0,0 +1,581 @@ +import 'dart:async'; + +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart' + show ZhtlcSyncParams; +import 'package:komodo_defi_sdk/komodo_defi_sdk.dart' + show + ZhtlcUserConfig, + ZcashParamsDownloader, + ZcashParamsDownloaderFactory, + DownloadProgress, + DownloadResultSuccess; +import 'package:komodo_defi_types/komodo_defi_types.dart' show Asset; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; + +enum ZhtlcSyncType { earliest, height, date } + +/// Shows ZHTLC configuration dialog similar to handleZhtlcConfigDialog from SDK example +/// This is bad practice (UI logic in utils), but necessary for now because of +/// auto-coin activations from multiple sources in BLoCs. +Future confirmZhtlcConfiguration( + BuildContext context, { + required Asset asset, +}) async { + String? prefilledZcashPath; + + if (ZcashParamsDownloaderFactory.requiresDownload) { + ZcashParamsDownloader? downloader; + try { + downloader = ZcashParamsDownloaderFactory.create(); + + final areAvailable = await downloader.areParamsAvailable(); + if (!areAvailable) { + final downloadResult = await _showZcashDownloadDialog( + context, + downloader, + ); + + if (downloadResult == false) { + // User cancelled the download + return null; + } + } + + prefilledZcashPath = await downloader.getParamsPath(); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(LocaleKeys.zhtlcErrorSettingUpZcash.tr(args: ['$e'])), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } finally { + downloader?.dispose(); + } + } + + return showDialog( + context: context, + barrierDismissible: false, + builder: (context) => ZhtlcConfigurationDialog( + asset: asset, + prefilledZcashPath: prefilledZcashPath, + ), + ); +} + +/// Stateful widget for ZHTLC configuration dialog +class ZhtlcConfigurationDialog extends StatefulWidget { + const ZhtlcConfigurationDialog({ + super.key, + required this.asset, + this.prefilledZcashPath, + }); + + final Asset asset; + final String? prefilledZcashPath; + + @override + State createState() => + _ZhtlcConfigurationDialogState(); +} + +class _ZhtlcConfigurationDialogState extends State { + late final TextEditingController zcashPathController; + late final TextEditingController blocksPerIterController; + late final TextEditingController intervalMsController; + StreamSubscription? _authSubscription; + bool _dismissedDueToAuthChange = false; + + final GlobalKey<_SyncFormState> _syncFormKey = GlobalKey<_SyncFormState>(); + + @override + void initState() { + super.initState(); + + // On web, use './zcash-params' as default, otherwise use prefilledZcashPath + // TODO: get from config factory constructor, or move to constants + final defaultZcashPath = kIsWeb + ? './zcash-params' + : widget.prefilledZcashPath; + zcashPathController = TextEditingController(text: defaultZcashPath); + blocksPerIterController = TextEditingController(text: '1000'); + intervalMsController = TextEditingController(text: '200'); + + _subscribeToAuthChanges(); + } + + @override + void dispose() { + _authSubscription?.cancel(); + zcashPathController.dispose(); + blocksPerIterController.dispose(); + intervalMsController.dispose(); + super.dispose(); + } + + void _handleSave() { + final path = zcashPathController.text.trim(); + // On web, allow empty path, otherwise require it + if (!kIsWeb && path.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(LocaleKeys.zhtlcZcashParamsRequired.tr())), + ); + return; + } + + // Create sync params based on type + final syncState = _syncFormKey.currentState; + final syncParams = syncState?.buildSyncParams(); + if (syncParams == null) { + return; + } + + final result = ZhtlcUserConfig( + zcashParamsPath: path, + scanBlocksPerIteration: + int.tryParse(blocksPerIterController.text) ?? 1000, + scanIntervalMs: int.tryParse(intervalMsController.text) ?? 0, + syncParams: syncParams, + ); + Navigator.of(context).pop(result); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text( + LocaleKeys.zhtlcConfigureTitle.tr(args: [widget.asset.id.id]), + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!kIsWeb) ...[ + TextField( + controller: zcashPathController, + readOnly: widget.prefilledZcashPath != null, + decoration: InputDecoration( + labelText: LocaleKeys.zhtlcZcashParamsPathLabel.tr(), + helperText: widget.prefilledZcashPath != null + ? LocaleKeys.zhtlcPathAutomaticallyDetected.tr() + : LocaleKeys.zhtlcSaplingParamsFolder.tr(), + ), + ), + const SizedBox(height: 12), + ], + TextField( + controller: blocksPerIterController, + decoration: InputDecoration( + labelText: LocaleKeys.zhtlcBlocksPerIterationLabel.tr(), + ), + keyboardType: TextInputType.number, + ), + const SizedBox(height: 12), + TextField( + controller: intervalMsController, + decoration: InputDecoration( + labelText: LocaleKeys.zhtlcScanIntervalLabel.tr(), + ), + keyboardType: TextInputType.number, + ), + const SizedBox(height: 12), + _SyncForm(key: _syncFormKey), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(LocaleKeys.cancel.tr()), + ), + FilledButton(onPressed: _handleSave, child: Text(LocaleKeys.ok.tr())), + ], + ); + } + + void _subscribeToAuthChanges() { + _authSubscription = context.read().stream.listen((state) { + if (state.currentUser == null) { + _handleAuthSignedOut(); + } + }); + } + + void _handleAuthSignedOut() { + if (_dismissedDueToAuthChange || !mounted) { + return; + } + + _dismissedDueToAuthChange = true; + Navigator.of(context).maybePop(null); + } +} + +class _SyncForm extends StatefulWidget { + const _SyncForm({super.key}); + + @override + State<_SyncForm> createState() => _SyncFormState(); +} + +class _SyncFormState extends State<_SyncForm> { + late final TextEditingController _syncValueController; + ZhtlcSyncType _syncType = ZhtlcSyncType.date; + DateTime? _selectedDate; + + @override + void initState() { + super.initState(); + _selectedDate = DateTime.now().subtract(const Duration(days: 2)); + _syncValueController = TextEditingController( + text: _formatDate(_selectedDate!), + ); + } + + @override + void dispose() { + _syncValueController.dispose(); + super.dispose(); + } + + ZhtlcSyncParams? buildSyncParams() { + switch (_syncType) { + case ZhtlcSyncType.earliest: + return ZhtlcSyncParams.earliest(); + case ZhtlcSyncType.height: + final rawValue = _syncValueController.text.trim(); + final parsedValue = int.tryParse(rawValue); + if (parsedValue == null) { + _showSnackBar(LocaleKeys.zhtlcInvalidBlockHeight.tr()); + return null; + } + return ZhtlcSyncParams.height(parsedValue); + case ZhtlcSyncType.date: + if (_selectedDate == null) { + return null; + } + final unixTimestamp = _selectedDate!.millisecondsSinceEpoch ~/ 1000; + return ZhtlcSyncParams.date(unixTimestamp); + } + } + + void _showSnackBar(String message) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + } + + Future _selectDate() async { + final picked = await showDatePicker( + context: context, + initialDate: _selectedDate ?? DateTime.now(), + firstDate: DateTime(2018), + lastDate: DateTime.now(), + builder: (context, child) { + return Theme( + data: _createMaterial3DatePickerTheme(), + child: child ?? const SizedBox(), + ); + }, + ); + + if (picked != null) { + setState(() { + _selectedDate = DateTime(picked.year, picked.month, picked.day); + _syncValueController.text = _formatDate(_selectedDate!); + }); + } + } + + void _onSyncTypeChanged(ZhtlcSyncType? newType) { + if (newType == null) { + return; + } + + setState(() { + _syncType = newType; + if (_syncType == ZhtlcSyncType.date) { + _selectedDate = DateTime.now().subtract(const Duration(days: 2)); + _syncValueController.text = _formatDate(_selectedDate!); + } else { + _selectedDate = null; + _syncValueController.clear(); + } + }); + } + + String _formatDate(DateTime dateTime) { + return dateTime.toIso8601String().split('T')[0]; + } + + ThemeData _createMaterial3DatePickerTheme() { + final currentTheme = Theme.of(context); + final currentColorScheme = currentTheme.colorScheme; + + final material3ColorScheme = ColorScheme.fromSeed( + seedColor: currentColorScheme.primary, + brightness: currentColorScheme.brightness, + ); + + return ThemeData( + useMaterial3: true, + colorScheme: material3ColorScheme, + fontFamily: currentTheme.textTheme.bodyMedium?.fontFamily, + ); + } + + String _syncTypeLabel(ZhtlcSyncType type) { + switch (type) { + case ZhtlcSyncType.earliest: + return LocaleKeys.zhtlcEarliestSaplingOption.tr(); + case ZhtlcSyncType.height: + return LocaleKeys.zhtlcBlockHeightOption.tr(); + case ZhtlcSyncType.date: + return LocaleKeys.zhtlcDateTimeOption.tr(); + } + } + + bool get _shouldShowValueField => _syncType != ZhtlcSyncType.earliest; + + bool get _isDate => _syncType == ZhtlcSyncType.date; + + bool get _isHeight => _syncType == ZhtlcSyncType.height; + + @override + Widget build(BuildContext context) { + final dropdownItems = ZhtlcSyncType.values + .map( + (type) => DropdownMenuItem( + value: type, + alignment: Alignment.centerLeft, + child: Text(_syncTypeLabel(type)), + ), + ) + .toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(LocaleKeys.zhtlcStartSyncFromLabel.tr()), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + child: DropdownButtonFormField( + initialValue: _syncType, + items: dropdownItems, + onChanged: _onSyncTypeChanged, + ), + ), + if (_shouldShowValueField) ...[ + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: _syncValueController, + decoration: InputDecoration( + labelText: _isHeight + ? LocaleKeys.zhtlcBlockHeightOption.tr() + : LocaleKeys.zhtlcSelectDateTimeLabel.tr(), + suffixIcon: _isDate + ? IconButton( + icon: const Icon(Icons.calendar_today), + onPressed: _selectDate, + ) + : null, + ), + keyboardType: _isHeight + ? TextInputType.number + : TextInputType.none, + readOnly: _isDate, + onTap: _isDate ? () => _selectDate() : null, + ), + ), + ], + ], + ), + if (_shouldShowValueField) ...[ + const SizedBox(height: 24), + if (_isDate) ...[const _SyncTimeWarning()], + ], + ], + ); + } +} + +class _SyncTimeWarning extends StatelessWidget { + const _SyncTimeWarning(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final backgroundColor = theme.colorScheme.secondaryContainer; + final foregroundColor = theme.colorScheme.onSecondaryContainer; + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: backgroundColor.withValues(alpha: 0.1), + border: Border.all(color: foregroundColor), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon(Icons.info_outline, color: foregroundColor), + const SizedBox(width: 12), + Expanded( + child: Text( + LocaleKeys.zhtlcDateSyncHint.tr(), + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: foregroundColor), + ), + ), + ], + ), + ); + } +} + +/// Shows a download progress dialog for Zcash parameters +Future _showZcashDownloadDialog( + BuildContext context, + ZcashParamsDownloader downloader, +) async { + return showDialog( + context: context, + barrierDismissible: false, + builder: (context) => ZcashDownloadProgressDialog(downloader: downloader), + ); +} + +/// Stateful widget for Zcash download progress dialog +class ZcashDownloadProgressDialog extends StatefulWidget { + const ZcashDownloadProgressDialog({required this.downloader, super.key}); + + final ZcashParamsDownloader downloader; + + @override + State createState() => + _ZcashDownloadProgressDialogState(); +} + +class _ZcashDownloadProgressDialogState + extends State { + static const downloadTimeout = Duration(minutes: 10); + bool downloadComplete = false; + bool downloadSuccess = false; + bool dialogClosed = false; + late Future downloadFuture; + + @override + void initState() { + super.initState(); + _startDownload(); + } + + void _startDownload() { + downloadFuture = widget.downloader + .downloadParams() + .timeout( + downloadTimeout, + onTimeout: () => throw TimeoutException( + 'Download timed out after ${downloadTimeout.inMinutes} minutes', + downloadTimeout, + ), + ) + .then((result) { + if (!downloadComplete && !dialogClosed && mounted) { + downloadComplete = true; + downloadSuccess = result is DownloadResultSuccess; + + // Close the dialog with the result + dialogClosed = true; + Navigator.of(context).pop(downloadSuccess); + } + }) + .catchError((Object e, StackTrace? stackTrace) { + if (!downloadComplete && !dialogClosed && mounted) { + downloadComplete = true; + downloadSuccess = false; + + debugPrint('Zcash parameters download failed: $e'); + if (stackTrace != null) { + debugPrint('Stack trace: $stackTrace'); + } + + // Indicate download failed (null result) + dialogClosed = true; + Navigator.of(context).pop(); + } + }); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(LocaleKeys.zhtlcDownloadingZcashParams.tr()), + content: SizedBox( + height: 120, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + StreamBuilder( + stream: widget.downloader.downloadProgress, + builder: (context, snapshot) { + if (snapshot.hasData) { + final progress = snapshot.data; + return Column( + children: [ + Text( + progress?.displayText ?? '', + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + LinearProgressIndicator( + value: (progress?.percentage ?? 0) / 100, + ), + Text( + '${(progress?.percentage ?? 0).toStringAsFixed(1)}%', + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.center, + ), + ], + ); + } + return Text(LocaleKeys.zhtlcPreparingDownload.tr()); + }, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () async { + if (!dialogClosed) { + dialogClosed = true; + await widget.downloader.cancelDownload(); + Navigator.of(context).pop(false); // Cancelled + } + }, + child: Text(LocaleKeys.cancel.tr()), + ), + ], + ); + } +} diff --git a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_handler.dart b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_handler.dart new file mode 100644 index 0000000000..ff581f2d6c --- /dev/null +++ b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_handler.dart @@ -0,0 +1,165 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:logging/logging.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart' show AssetId; +import 'package:web_dex/services/arrr_activation/arrr_activation_service.dart'; +import 'package:web_dex/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart' + show confirmZhtlcConfiguration; + +/// Widget that handles ZHTLC configuration dialogs automatically +/// by listening to ArrrActivationService for configuration requests +class ZhtlcConfigurationHandler extends StatefulWidget { + const ZhtlcConfigurationHandler({super.key, required this.child}); + + final Widget child; + + @override + State createState() => + _ZhtlcConfigurationHandlerState(); +} + +class _ZhtlcConfigurationHandlerState extends State { + late StreamSubscription _configRequestSubscription; + late final ArrrActivationService _arrrActivationService; + StreamSubscription? _authSubscription; + final Logger _log = Logger('ZhtlcConfigurationHandler'); + + @override + void initState() { + super.initState(); + _arrrActivationService = RepositoryProvider.of( + context, + ); + _listenToConfigurationRequests(); + _subscribeToAuthChanges(); + } + + @override + void dispose() { + _configRequestSubscription.cancel(); + _authSubscription?.cancel(); + super.dispose(); + } + + void _listenToConfigurationRequests() { + // Listen to configuration requests from the ArrrActivationService + _log.info('Setting up configuration request listener'); + _configRequestSubscription = _arrrActivationService.configurationRequests + .listen( + (configRequest) { + _log.info( + 'Received config request for ${configRequest.asset.id.id}', + ); + if (mounted && + !_handlingConfigurations.contains(configRequest.asset.id)) { + _log.info( + 'Showing configuration dialog for ${configRequest.asset.id.id}', + ); + _showConfigurationDialog(context, configRequest); + } else { + _log.warning( + 'Skipping config request for ${configRequest.asset.id.id} ' + '(mounted: $mounted, already handling: ${_handlingConfigurations.contains(configRequest.asset.id)})', + ); + } + }, + onError: (error, stackTrace) { + _log.severe( + 'Error in configuration request stream', + error, + stackTrace, + ); + }, + onDone: () { + _log.warning('Configuration request stream closed unexpectedly'); + }, + ); + } + + // Track which configuration requests are already being handled to prevent duplicates + static final Set _handlingConfigurations = {}; + + @override + Widget build(BuildContext context) { + return widget.child; + } + + Future _showConfigurationDialog( + BuildContext context, + ZhtlcConfigurationRequest configRequest, + ) async { + _handlingConfigurations.add(configRequest.asset.id); + _log.info('Starting configuration dialog for ${configRequest.asset.id.id}'); + + try { + if (!mounted || !context.mounted) { + _log.warning( + 'Context not mounted, cancelling configuration for ${configRequest.asset.id.id}', + ); + _arrrActivationService.cancelConfiguration(configRequest.asset.id); + return; + } + + final config = await confirmZhtlcConfiguration( + context, + asset: configRequest.asset, + ); + + if (config != null) { + _log.info( + 'User provided configuration for ${configRequest.asset.id.id}', + ); + _arrrActivationService.submitConfiguration( + configRequest.asset.id, + config, + ); + } else { + _log.info( + 'User cancelled configuration for ${configRequest.asset.id.id}', + ); + _arrrActivationService.cancelConfiguration(configRequest.asset.id); + } + } catch (e, stackTrace) { + _log.severe( + 'Error in configuration dialog for ${configRequest.asset.id.id}', + e, + stackTrace, + ); + _arrrActivationService.cancelConfiguration(configRequest.asset.id); + } finally { + _handlingConfigurations.remove(configRequest.asset.id); + _log.info( + 'Finished handling configuration for ${configRequest.asset.id.id}', + ); + } + } + + /// Check if the configuration request listener is active + bool get isListeningToConfigurationRequests => + !_configRequestSubscription.isPaused; + + void _subscribeToAuthChanges() { + _authSubscription = context.read().stream.listen((state) { + if (state.currentUser == null) { + _handleSignedOut(); + } + }); + } + + void _handleSignedOut() { + if (_handlingConfigurations.isEmpty) { + return; + } + + _log.info('Auth signed out - clearing pending ZHTLC configuration state'); + final pendingAssetIds = List.of(_handlingConfigurations); + _handlingConfigurations.clear(); + + for (final assetId in pendingAssetIds) { + _arrrActivationService.cancelConfiguration(assetId); + } + } +} diff --git a/lib/views/wallet/wallet_page/wallet_main/active_coins_list.dart b/lib/views/wallet/wallet_page/wallet_main/active_coins_list.dart index 7e53464061..18d4303b35 100644 --- a/lib/views/wallet/wallet_page/wallet_main/active_coins_list.dart +++ b/lib/views/wallet/wallet_page/wallet_main/active_coins_list.dart @@ -13,12 +13,14 @@ import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/coin_utils.dart'; import 'package:web_dex/shared/utils/utils.dart'; import 'package:web_dex/shared/utils/formatters.dart'; +import 'package:web_dex/services/arrr_activation/arrr_activation_service.dart'; import 'package:web_dex/views/wallet/coin_details/coin_details_info/coin_addresses.dart'; import 'package:web_dex/views/wallet/common/address_copy_button.dart'; import 'package:web_dex/views/wallet/common/address_icon.dart'; import 'package:web_dex/views/wallet/common/address_text.dart'; import 'package:web_dex/views/wallet/wallet_page/common/expandable_coin_list_item.dart'; +import 'package:web_dex/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart'; class ActiveCoinsList extends StatelessWidget { const ActiveCoinsList({ @@ -26,11 +28,13 @@ class ActiveCoinsList extends StatelessWidget { required this.searchPhrase, required this.withBalance, required this.onCoinItemTap, + this.arrrActivationService, }); final String searchPhrase; final bool withBalance; final Function(Coin) onCoinItemTap; + final ArrrActivationService? arrrActivationService; @override Widget build(BuildContext context) { @@ -62,29 +66,44 @@ class ActiveCoinsList extends StatelessWidget { sorted = removeTestCoins(sorted); } - return SliverList.builder( - itemCount: sorted.length, - itemBuilder: (context, index) { - final coin = sorted[index]; - - // Fetch pubkeys if not already loaded - if (!state.pubkeys.containsKey(coin.abbr)) { - // TODO: Investigate if this is causing performance issues - context.read().add(CoinsPubkeysRequested(coin.abbr)); - } - - return Padding( - padding: EdgeInsets.only(bottom: 10), - child: ExpandableCoinListItem( - // Changed from ExpandableCoinListItem - key: Key('coin-list-item-${coin.abbr.toLowerCase()}'), - coin: coin, - pubkeys: state.pubkeys[coin.abbr], - isSelected: false, - onTap: () => onCoinItemTap(coin), + return SliverMainAxisGroup( + slivers: [ + // ZHTLC Activation Status Bar + if (arrrActivationService != null) + SliverToBoxAdapter( + child: ZhtlcActivationStatusBar( + activationService: arrrActivationService!, + ), ), - ); - }, + + // Coin List + SliverList.builder( + itemCount: sorted.length, + itemBuilder: (context, index) { + final coin = sorted[index]; + + // Fetch pubkeys if not already loaded + if (!state.pubkeys.containsKey(coin.abbr)) { + // TODO: Investigate if this is causing performance issues + context.read().add( + CoinsPubkeysRequested(coin.abbr), + ); + } + + return Padding( + padding: EdgeInsets.only(bottom: 10), + child: ExpandableCoinListItem( + // Changed from ExpandableCoinListItem + key: Key('coin-list-item-${coin.abbr.toLowerCase()}'), + coin: coin, + pubkeys: state.pubkeys[coin.abbr], + isSelected: false, + onTap: () => onCoinItemTap(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 9c4f375e7e..01f72c75a0 100644 --- a/lib/views/wallet/wallet_page/wallet_main/wallet_main.dart +++ b/lib/views/wallet/wallet_page/wallet_main/wallet_main.dart @@ -34,6 +34,7 @@ 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'; +import 'package:web_dex/services/arrr_activation/arrr_activation_service.dart'; import 'package:web_dex/views/common/page_header/page_header.dart'; import 'package:web_dex/views/common/pages/page_layout.dart'; import 'package:web_dex/views/dex/dex_helpers.dart'; @@ -41,6 +42,8 @@ import 'package:web_dex/views/wallet/coin_details/coin_details_info/charts/portf import 'package:web_dex/views/wallet/coin_details/coin_details_info/charts/portfolio_profit_loss_chart.dart'; import 'package:web_dex/views/wallet/wallet_page/charts/coin_prices_chart.dart'; import 'package:web_dex/views/wallet/wallet_page/common/assets_list.dart'; +import 'package:web_dex/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_handler.dart' + show ZhtlcConfigurationHandler; import 'package:web_dex/views/wallet/wallet_page/wallet_main/active_coins_list.dart'; import 'package:web_dex/views/wallet/wallet_page/wallet_main/wallet_manage_section.dart'; import 'package:web_dex/views/wallet/wallet_page/wallet_main/wallet_overview.dart'; @@ -129,65 +132,70 @@ class _WalletMainState extends State with TickerProviderStateMixin { : AuthorizeMode.logIn; final isLoggedIn = authStateMode == AuthorizeMode.logIn; - return BlocBuilder( - builder: (context, state) { - final walletCoinsFiltered = state.walletCoins.values.toList(); - - return PageLayout( - noBackground: true, - header: (isMobile && !isLoggedIn) - ? PageHeader(title: LocaleKeys.wallet.tr()) - : null, - padding: EdgeInsets.zero, - // Removed page padding here - content: Expanded( - child: Listener( - onPointerSignal: _onPointerSignal, - child: CustomScrollView( - key: const Key('wallet-page-scroll-view'), - controller: _scrollController, - slivers: [ - // Add a SizedBox at the top of the sliver list for spacing - if (isLoggedIn) ...[ - if (!isMobile) - const SliverToBoxAdapter(child: SizedBox(height: 32)), - SliverToBoxAdapter( - child: WalletOverview( - key: const Key('wallet-overview'), - onPortfolioGrowthPressed: () => - _tabController.animateTo(1), - onPortfolioProfitLossPressed: () => - _tabController.animateTo(2), - onAssetsPressed: () => _tabController.animateTo(0), + return ZhtlcConfigurationHandler( + child: BlocBuilder( + builder: (context, state) { + final walletCoinsFiltered = state.walletCoins.values.toList(); + + return PageLayout( + noBackground: true, + header: (isMobile && !isLoggedIn) + ? PageHeader(title: LocaleKeys.wallet.tr()) + : null, + padding: EdgeInsets.zero, + // Removed page padding here + content: Expanded( + child: Listener( + onPointerSignal: _onPointerSignal, + child: CustomScrollView( + key: const Key('wallet-page-scroll-view'), + controller: _scrollController, + slivers: [ + // Add a SizedBox at the top of the sliver list for spacing + if (isLoggedIn) ...[ + if (!isMobile) + const SliverToBoxAdapter( + child: SizedBox(height: 32), + ), + SliverToBoxAdapter( + child: WalletOverview( + key: const Key('wallet-overview'), + onPortfolioGrowthPressed: () => + _tabController.animateTo(1), + onPortfolioProfitLossPressed: () => + _tabController.animateTo(2), + onAssetsPressed: () => + _tabController.animateTo(0), + ), ), - ), - const SliverToBoxAdapter(child: Gap(24)), - ], - SliverPersistentHeader( - pinned: true, - delegate: _SliverTabBarDelegate( - TabBar( - controller: _tabController, - tabs: [ - Tab(text: LocaleKeys.assets.tr()), - if (isLoggedIn) - Tab(text: LocaleKeys.portfolioGrowth.tr()) - else - Tab(text: LocaleKeys.statistics.tr()), - if (isLoggedIn) - Tab(text: LocaleKeys.profitAndLoss.tr()), - ], + const SliverToBoxAdapter(child: Gap(24)), + ], + SliverPersistentHeader( + pinned: true, + delegate: _SliverTabBarDelegate( + TabBar( + controller: _tabController, + tabs: [ + Tab(text: LocaleKeys.assets.tr()), + if (isLoggedIn) + Tab(text: LocaleKeys.portfolioGrowth.tr()) + else + Tab(text: LocaleKeys.statistics.tr()), + if (isLoggedIn) + Tab(text: LocaleKeys.profitAndLoss.tr()), + ], + ), ), ), - ), - if (!isMobile) SliverToBoxAdapter(child: Gap(24)), - ..._buildTabSlivers(authStateMode, walletCoinsFiltered), - ], + if (!isMobile) SliverToBoxAdapter(child: Gap(24)), + ..._buildTabSlivers(authStateMode, walletCoinsFiltered), + ], + ), ), ), - ), - ); - }, + ); + }, + ), ); }, ); @@ -226,20 +234,14 @@ class _WalletMainState extends State with TickerProviderStateMixin { ), ); - assetOverviewBloc - ..add( - PortfolioAssetsOverviewLoadRequested( - coins: walletCoins, - walletId: walletId, - ), - ) - ..add( - PortfolioAssetsOverviewSubscriptionRequested( - coins: walletCoins, - walletId: walletId, - updateFrequency: const Duration(minutes: 1), - ), - ); + // Subscribe fires an immediate load event, so no need to also call load + assetOverviewBloc.add( + PortfolioAssetsOverviewSubscriptionRequested( + coins: walletCoins, + walletId: walletId, + updateFrequency: const Duration(minutes: 1), + ), + ); } void _clearWalletData() { @@ -253,9 +255,9 @@ class _WalletMainState extends State with TickerProviderStateMixin { } void _onShowCoinsWithBalanceClick(bool value) { - context - .read() - .add(HideZeroBalanceAssetsChanged(hideZeroBalanceAssets: value)); + context.read().add( + HideZeroBalanceAssetsChanged(hideZeroBalanceAssets: value), + ); } void _onSearchChange(String searchKey) { @@ -276,11 +278,8 @@ class _WalletMainState extends State with TickerProviderStateMixin { void _onAssetStatisticsTap(AssetId assetId, Duration period) { context.read().add( - PriceChartStarted( - symbols: [assetId.symbol.configSymbol], - period: period, - ), - ); + PriceChartStarted(symbols: [assetId.symbol.configSymbol], period: period), + ); _tabController.animateTo(1); } @@ -291,8 +290,10 @@ class _WalletMainState extends State with TickerProviderStateMixin { SliverPersistentHeader( pinned: true, delegate: _SliverSearchBarDelegate( - withBalance: - context.watch().state.hideZeroBalanceAssets, + withBalance: context + .watch() + .state + .hideZeroBalanceAssets, onSearchChange: _onSearchChange, onWithBalanceChange: _onShowCoinsWithBalanceClick, mode: mode, @@ -302,8 +303,10 @@ class _WalletMainState extends State with TickerProviderStateMixin { CoinListView( mode: mode, searchPhrase: _searchKey, - withBalance: - context.watch().state.hideZeroBalanceAssets, + withBalance: context + .watch() + .state + .hideZeroBalanceAssets, onActiveCoinItemTap: _onActiveCoinItemTap, onAssetItemTap: _onAssetItemTap, onAssetStatisticsTap: _onAssetStatisticsTap, @@ -345,11 +348,11 @@ class _WalletMainState extends State with TickerProviderStateMixin { _walletHalfLogged = true; final coinsCount = context.read().state.walletCoins.length; context.read().logEvent( - WalletListHalfViewportReachedEventData( - timeToHalfMs: _walletListStopwatch.elapsedMilliseconds, - walletSize: coinsCount, - ), - ); + WalletListHalfViewportReachedEventData( + timeToHalfMs: _walletListStopwatch.elapsedMilliseconds, + walletSize: coinsCount, + ), + ); } } @@ -362,11 +365,11 @@ class _WalletMainState extends State with TickerProviderStateMixin { if (newOffset == _scrollController.offset) { context.read().logEvent( - ScrollAttemptOutsideContentEventData( - screenContext: 'wallet_page', - scrollDelta: event.scrollDelta.dy, - ), - ); + ScrollAttemptOutsideContentEventData( + screenContext: 'wallet_page', + scrollDelta: event.scrollDelta.dy, + ), + ); return; } @@ -421,6 +424,9 @@ class CoinListView extends StatelessWidget { searchPhrase: searchPhrase, withBalance: withBalance, onCoinItemTap: onActiveCoinItemTap, + arrrActivationService: RepositoryProvider.of( + context, + ), ); case AuthorizeMode.hiddenLogin: case AuthorizeMode.noLogin: @@ -437,8 +443,8 @@ class CoinListView extends StatelessWidget { searchPhrase: searchPhrase, onAssetItemTap: (assetId) => onAssetItemTap( context.read().state.coins.values.firstWhere( - (coin) => coin.assetId == assetId, - ), + (coin) => coin.assetId == assetId, + ), ), onStatisticsTap: onAssetStatisticsTap, ); @@ -471,8 +477,10 @@ class _SliverSearchBarDelegate extends SliverPersistentHeaderDelegate { bool overlapsContent, ) { // Apply collapse progress on both mobile and desktop - final collapseProgress = - (shrinkOffset / (maxExtent - minExtent)).clamp(0.0, 1.0); + final collapseProgress = (shrinkOffset / (maxExtent - minExtent)).clamp( + 0.0, + 1.0, + ); return SizedBox( height: (maxExtent - shrinkOffset).clamp(minExtent, maxExtent), diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 200fa59fe5..0345a714f6 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -218,7 +218,7 @@ SPEC CHECKSUMS: FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 GoogleAppMeasurement: 700dce7541804bec33db590a5c496b663fbe2539 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 - komodo_defi_framework: e4ae27a407c2d1f1f8b11217b0077e6b7511cd72 + komodo_defi_framework: 725599127b357521f4567b16192bf07d7ad1d4b0 local_auth_darwin: d2e8c53ef0c4f43c646462e3415432c4dab3ae19 mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 diff --git a/pubspec.lock b/pubspec.lock index 1d92cea1e3..e3a079fec9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -617,10 +617,10 @@ packages: dependency: transitive description: name: hive_ce_flutter - sha256: a0989670652eab097b47544f1e5a4456e861b1b01b050098ea0b80a5fabe9909 + sha256: f5bd57fda84402bca7557fedb8c629c96c8ea10fab4a542968d7b60864ca02cc url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" hive_flutter: dependency: "direct main" description: @@ -913,7 +913,7 @@ packages: source: hosted version: "7.0.1" mutex: - dependency: transitive + dependency: "direct main" description: name: mutex sha256: "8827da25de792088eb33e572115a5eb0d61d61a3c01acbc8bcbe76ed78f1a1f2" diff --git a/pubspec.yaml b/pubspec.yaml index e5cdefafc6..da72d48986 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -75,6 +75,7 @@ dependencies: cross_file: 0.3.4+2 # flutter.dev video_player: ^2.9.5 # flutter.dev logging: 1.3.0 + mutex: ^3.1.0 integration_test: # SDK (moved from dev_dependencies to ensure Android release build includes plugin) sdk: flutter diff --git a/sdk b/sdk index ab65b107a0..b76012e0e6 160000 --- a/sdk +++ b/sdk @@ -1 +1 @@ -Subproject commit ab65b107a00372f1491e362c9d4b4dabe89fb3e8 +Subproject commit b76012e0e6c8b4db83320d8c710eebf884e00721 diff --git a/test_integration/bloc/cex_market_data/common/update_frequency_backoff_strategy_integration_test.dart b/test_integration/bloc/cex_market_data/common/update_frequency_backoff_strategy_integration_test.dart new file mode 100644 index 0000000000..aa4ffd11f9 --- /dev/null +++ b/test_integration/bloc/cex_market_data/common/update_frequency_backoff_strategy_integration_test.dart @@ -0,0 +1,136 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:web_dex/bloc/cex_market_data/common/update_frequency_backoff_strategy.dart'; + +void main() { + group('UpdateFrequencyBackoffStrategy Integration Tests', () { + test('should demonstrate realistic backoff progression over time', () { + final strategy = UpdateFrequencyBackoffStrategy(); + final List actualIntervals = []; + + // Simulate 20 update attempts + for (int i = 0; i < 20; i++) { + actualIntervals.add(strategy.getNextInterval()); + } + + // Verify the pattern: 2min pairs, then 4min pairs, then 8min pairs, etc. + expect(actualIntervals[0], const Duration(minutes: 2)); // Attempt 0 + expect(actualIntervals[1], const Duration(minutes: 2)); // Attempt 1 + expect(actualIntervals[2], const Duration(minutes: 4)); // Attempt 2 + expect(actualIntervals[3], const Duration(minutes: 4)); // Attempt 3 + expect(actualIntervals[4], const Duration(minutes: 8)); // Attempt 4 + expect(actualIntervals[5], const Duration(minutes: 8)); // Attempt 5 + expect(actualIntervals[6], const Duration(minutes: 16)); // Attempt 6 + expect(actualIntervals[7], const Duration(minutes: 16)); // Attempt 7 + expect(actualIntervals[8], const Duration(minutes: 32)); // Attempt 8 + expect(actualIntervals[9], const Duration(minutes: 32)); // Attempt 9 + expect(actualIntervals[10], const Duration(minutes: 60)); // Capped at 1 hour + expect(actualIntervals[11], const Duration(minutes: 60)); // Capped at 1 hour + + // Verify that all subsequent intervals are capped at max + for (int i = 12; i < actualIntervals.length; i++) { + expect(actualIntervals[i], const Duration(minutes: 60)); + } + }); + + test('should reduce API calls over time compared to fixed interval', () { + final strategy = UpdateFrequencyBackoffStrategy(); + + // Calculate total time and API calls over 24 hours with backoff strategy + const simulationDuration = Duration(hours: 24); + int backoffApiCalls = 0; + Duration totalBackoffTime = Duration.zero; + + while (totalBackoffTime < simulationDuration) { + final interval = strategy.getNextInterval(); + totalBackoffTime += interval; + backoffApiCalls++; + } + + // Calculate API calls with fixed 2-minute interval + const fixedInterval = Duration(minutes: 2); + final fixedApiCalls = simulationDuration.inMinutes ~/ fixedInterval.inMinutes; + + // Backoff strategy should make significantly fewer API calls + expect(backoffApiCalls, lessThan(fixedApiCalls)); + expect(backoffApiCalls, lessThan(fixedApiCalls * 0.5)); // Less than 50% of fixed calls + + print('Fixed interval (2min): $fixedApiCalls API calls in 24h'); + print('Backoff strategy: $backoffApiCalls API calls in 24h'); + print('Reduction: ${((fixedApiCalls - backoffApiCalls) / fixedApiCalls * 100).toStringAsFixed(1)}%'); + }); + + test('should recover quickly after reset', () { + final strategy = UpdateFrequencyBackoffStrategy(); + + // Advance to high attempt count + for (int i = 0; i < 10; i++) { + strategy.getNextInterval(); + } + + // Should be at a high interval + expect(strategy.getCurrentInterval(), greaterThan(const Duration(minutes: 10))); + + // Reset and verify quick recovery + strategy.reset(); + expect(strategy.getNextInterval(), const Duration(minutes: 2)); + expect(strategy.getNextInterval(), const Duration(minutes: 2)); + expect(strategy.getNextInterval(), const Duration(minutes: 4)); + }); + + test('should handle custom intervals for different use cases', () { + // Test for a more aggressive backoff (shorter max interval) + final aggressiveStrategy = UpdateFrequencyBackoffStrategy( + baseInterval: const Duration(minutes: 1), + maxInterval: const Duration(minutes: 10), + ); + + // Test for a more conservative backoff (longer base interval) + final conservativeStrategy = UpdateFrequencyBackoffStrategy( + baseInterval: const Duration(minutes: 5), + maxInterval: const Duration(hours: 2), + ); + + // Aggressive should reach max quickly (after 6 attempts: 1,1,2,2,4,4,8...) + for (int i = 0; i < 6; i++) { + aggressiveStrategy.getNextInterval(); + } + expect(aggressiveStrategy.getCurrentInterval(), const Duration(minutes: 8)); + + // Conservative should start and progress more slowly + expect(conservativeStrategy.getNextInterval(), const Duration(minutes: 5)); + expect(conservativeStrategy.getNextInterval(), const Duration(minutes: 5)); + expect(conservativeStrategy.getNextInterval(), const Duration(minutes: 10)); + expect(conservativeStrategy.getNextInterval(), const Duration(minutes: 10)); + }); + + test('should be suitable for portfolio update scenarios', () { + final strategy = UpdateFrequencyBackoffStrategy(); + + // First hour of updates (user just logged in) + final firstHourIntervals = []; + Duration elapsed = Duration.zero; + const oneHour = Duration(hours: 1); + + while (elapsed < oneHour) { + final interval = strategy.getNextInterval(); + firstHourIntervals.add(interval); + elapsed += interval; + } + + // Should have frequent updates in the first hour + expect(firstHourIntervals.length, greaterThan(5)); + expect(firstHourIntervals.length, lessThan(30)); // But not too frequent + + // First few updates should be relatively quick + expect(firstHourIntervals[0], const Duration(minutes: 2)); + expect(firstHourIntervals[1], const Duration(minutes: 2)); + + // Later updates should be less frequent + final lastInterval = firstHourIntervals.last; + expect(lastInterval, greaterThan(const Duration(minutes: 2))); + + print('Updates in first hour: ${firstHourIntervals.length}'); + print('Intervals: ${firstHourIntervals.map((d) => '${d.inMinutes}min').join(', ')}'); + }); + }); +} \ No newline at end of file diff --git a/test_units/bloc/cex_market_data/common/update_frequency_backoff_strategy_test.dart b/test_units/bloc/cex_market_data/common/update_frequency_backoff_strategy_test.dart new file mode 100644 index 0000000000..6dbb7c5fbc --- /dev/null +++ b/test_units/bloc/cex_market_data/common/update_frequency_backoff_strategy_test.dart @@ -0,0 +1,164 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:web_dex/bloc/cex_market_data/common/update_frequency_backoff_strategy.dart'; + +void main() { + group('UpdateFrequencyBackoffStrategy', () { + late UpdateFrequencyBackoffStrategy strategy; + + setUp(() { + strategy = UpdateFrequencyBackoffStrategy(); + }); + + test('should start with attempt count 0', () { + expect(strategy.attemptCount, 0); + }); + + test('should return base interval for first two attempts', () { + expect(strategy.getCurrentInterval(), const Duration(minutes: 2)); + expect(strategy.getNextInterval(), const Duration(minutes: 2)); + expect(strategy.attemptCount, 1); + + expect(strategy.getCurrentInterval(), const Duration(minutes: 2)); + expect(strategy.getNextInterval(), const Duration(minutes: 2)); + expect(strategy.attemptCount, 2); + }); + + test('should double interval for next pair of attempts', () { + // Skip first two attempts + strategy.getNextInterval(); // 2 min + strategy.getNextInterval(); // 2 min + + expect(strategy.getCurrentInterval(), const Duration(minutes: 4)); + expect(strategy.getNextInterval(), const Duration(minutes: 4)); + expect(strategy.attemptCount, 3); + + expect(strategy.getCurrentInterval(), const Duration(minutes: 4)); + expect(strategy.getNextInterval(), const Duration(minutes: 4)); + expect(strategy.attemptCount, 4); + }); + + test('should follow exponential backoff pattern: 2,2,4,4,8,8,16,16', () { + final expectedIntervals = [ + const Duration(minutes: 2), // attempt 0 + const Duration(minutes: 2), // attempt 1 + const Duration(minutes: 4), // attempt 2 + const Duration(minutes: 4), // attempt 3 + const Duration(minutes: 8), // attempt 4 + const Duration(minutes: 8), // attempt 5 + const Duration(minutes: 16), // attempt 6 + const Duration(minutes: 16), // attempt 7 + ]; + + for (int i = 0; i < expectedIntervals.length; i++) { + expect( + strategy.getNextInterval(), + expectedIntervals[i], + reason: 'Attempt $i should return ${expectedIntervals[i]}', + ); + } + }); + + test('should cap at maximum interval', () { + strategy = UpdateFrequencyBackoffStrategy( + baseInterval: const Duration(minutes: 1), + maxInterval: const Duration(minutes: 5), + ); + + // Skip to high attempt count to reach max + for (int i = 0; i < 10; i++) { + strategy.getNextInterval(); + } + + // Should be capped at 5 minutes + expect(strategy.getCurrentInterval(), const Duration(minutes: 5)); + }); + + test('should reset to initial state', () { + // Make some attempts + strategy.getNextInterval(); + strategy.getNextInterval(); + strategy.getNextInterval(); + + expect(strategy.attemptCount, 3); + expect(strategy.getCurrentInterval(), const Duration(minutes: 4)); + + // Reset + strategy.reset(); + + expect(strategy.attemptCount, 0); + expect(strategy.getCurrentInterval(), const Duration(minutes: 2)); + }); + + test('should always return true for shouldUpdateCache', () { + // Test for various attempt counts + for (int i = 0; i < 10; i++) { + expect(strategy.shouldUpdateCache(), true); + strategy.getNextInterval(); + } + }); + + test('should preview next intervals without changing state', () { + // Start at attempt count 0 + expect(strategy.attemptCount, 0); + + final preview = strategy.previewNextIntervals(6); + + // State should be unchanged + expect(strategy.attemptCount, 0); + + // Preview should show correct intervals + expect(preview, [ + const Duration(minutes: 2), // attempt 0 + const Duration(minutes: 2), // attempt 1 + const Duration(minutes: 4), // attempt 2 + const Duration(minutes: 4), // attempt 3 + const Duration(minutes: 8), // attempt 4 + const Duration(minutes: 8), // attempt 5 + ]); + }); + + test('should preview intervals from current position', () { + // Advance to attempt 2 + strategy.getNextInterval(); // 2 min + strategy.getNextInterval(); // 2 min + + expect(strategy.attemptCount, 2); + + final preview = strategy.previewNextIntervals(4); + + // Should show intervals starting from attempt 2 + expect(preview, [ + const Duration(minutes: 4), // attempt 2 + const Duration(minutes: 4), // attempt 3 + const Duration(minutes: 8), // attempt 4 + const Duration(minutes: 8), // attempt 5 + ]); + + // State should be unchanged + expect(strategy.attemptCount, 2); + }); + + test('should handle custom base and max intervals', () { + strategy = UpdateFrequencyBackoffStrategy( + baseInterval: const Duration(minutes: 1), + maxInterval: const Duration(minutes: 3), + ); + + final intervals = [ + strategy.getNextInterval(), // 1min + strategy.getNextInterval(), // 1min + strategy.getNextInterval(), // 2min + strategy.getNextInterval(), // 2min + strategy.getNextInterval(), // 3min (capped at max) + ]; + + expect(intervals, [ + const Duration(minutes: 1), + const Duration(minutes: 1), + const Duration(minutes: 2), + const Duration(minutes: 2), + const Duration(minutes: 3), // Capped + ]); + }); + }); +} \ No newline at end of file diff --git a/test_units/tests/views/dex/simple/form/tables/table_utils_test.dart b/test_units/tests/views/dex/simple/form/tables/table_utils_test.dart new file mode 100644 index 0000000000..1031f9015c --- /dev/null +++ b/test_units/tests/views/dex/simple/form/tables/table_utils_test.dart @@ -0,0 +1,226 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart' + show AssetChainId, AssetId, CoinSubClass; +import 'package:komodo_defi_types/src/assets/asset_symbol.dart'; +import 'package:rational/rational.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/best_orders/best_orders.dart'; +import 'package:web_dex/model/authorize_mode.dart'; +import 'package:web_dex/model/cex_price.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/coin_type.dart'; +import 'package:web_dex/views/dex/simple/form/tables/table_utils.dart'; + +Coin _buildCoin( + String abbr, { + double usdPrice = 0, + bool walletOnly = false, + bool isTestCoin = false, + int priority = 0, +}) { + final assetId = AssetId( + id: abbr, + name: '$abbr Coin', + symbol: AssetSymbol(assetConfigId: abbr), + chainId: AssetChainId(chainId: 1), + derivationPath: null, + subClass: CoinSubClass.utxo, + ); + + return Coin( + type: CoinType.utxo, + abbr: abbr, + id: assetId, + name: '$abbr Coin', + explorerUrl: 'https://example.com/$abbr', + explorerTxUrl: 'https://example.com/$abbr/tx', + explorerAddressUrl: 'https://example.com/$abbr/address', + protocolType: 'UTXO', + protocolData: null, + isTestCoin: isTestCoin, + logoImageUrl: null, + coingeckoId: null, + fallbackSwapContract: null, + priority: priority, + state: CoinState.active, + swapContractAddress: null, + walletOnly: walletOnly, + mode: CoinMode.standard, + usdPrice: CexPrice( + assetId: assetId, + price: Decimal.parse(usdPrice.toString()), + change24h: Decimal.zero, + lastUpdated: DateTime.fromMillisecondsSinceEpoch(0), + ), + ); +} + +BestOrder _buildOrder(String coin, int price) { + return BestOrder( + price: Rational.fromInt(price), + maxVolume: Rational.fromInt(1), + minVolume: Rational.fromInt(1), + coin: coin, + address: OrderAddress.transparent(coin.toLowerCase()), + uuid: '$coin-$price', + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('buildOrderCoinCaches', () { + testWidgets('creates aligned caches for orders and coins', (tester) async { + final btc = _buildCoin('BTC', usdPrice: 30_000); + final kmd = _buildCoin('KMD', usdPrice: 1); + final coins = {'BTC': btc, 'KMD': kmd}; + final coinLookup = (String abbr) => coins[abbr]; + + final orders = >{ + 'BTC-KMD': [_buildOrder('BTC', 1)], + 'KMD-BTC': [_buildOrder('KMD', 2)], + }; + + late ({ + Map ordersByAssetId, + Map coinsByAssetId, + Map assetIdByAbbr, + }) + caches; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + caches = buildOrderCoinCaches( + context, + orders, + coinLookup: coinLookup, + ); + return const SizedBox.shrink(); + }, + ), + ), + ); + + expect(caches.ordersByAssetId.length, 2); + expect(caches.coinsByAssetId.length, 2); + expect(caches.assetIdByAbbr['BTC'], btc.assetId); + expect(caches.ordersByAssetId[btc.assetId]?.uuid, 'BTC-1'); + }); + }); + + group('prepareOrdersForTable', () { + testWidgets('sorts by fiat value and filters wallet/test coins', ( + tester, + ) async { + final btc = _buildCoin('BTC', usdPrice: 30_000); + final kmd = _buildCoin('KMD', usdPrice: 1, walletOnly: true); + final tbtc = _buildCoin('TBTC', usdPrice: 25_000, isTestCoin: true); + final coins = {'BTC': btc, 'KMD': kmd, 'TBTC': tbtc}; + final coinLookup = (String abbr) => coins[abbr]; + + final orders = >{ + 'BTC-KMD': [_buildOrder('BTC', 1)], + 'KMD-BTC': [_buildOrder('KMD', 2)], + 'TBTC-KMD': [_buildOrder('TBTC', 3)], + }; + + late List sorted; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + sorted = prepareOrdersForTable( + context, + orders, + null, + AuthorizeMode.noLogin, + testCoinsEnabled: false, + coinLookup: coinLookup, + ); + return const SizedBox.shrink(); + }, + ), + ), + ); + + expect(sorted, hasLength(1)); + expect(sorted.single.coin, 'BTC'); + }); + + testWidgets('uses fewer coin lookups than the legacy approach', ( + tester, + ) async { + final btc = _buildCoin('BTC', usdPrice: 30_000); + final kmd = _buildCoin('KMD', usdPrice: 1); + final coins = {'BTC': btc, 'KMD': kmd}; + + final orders = >{ + 'pair-1': [_buildOrder('BTC', 1)], + 'pair-2': [_buildOrder('KMD', 100)], + }; + + final legacyCalls = {}; + final optimisedCalls = {}; + + Coin? legacyLookup(String abbr) { + legacyCalls[abbr] = (legacyCalls[abbr] ?? 0) + 1; + return coins[abbr]; + } + + Coin? optimisedLookup(String abbr) { + optimisedCalls[abbr] = (optimisedCalls[abbr] ?? 0) + 1; + return coins[abbr]; + } + + List legacyPrepare( + Map> input, + Coin? Function(String) lookup, + ) { + final result = []; + input.forEach((_, list) { + if (list.isEmpty) return; + final order = list.first; + final coin = lookup(order.coin); + if (coin == null) return; + result.add(order); + }); + result.sort((a, b) { + final coinA = lookup(a.coin); + final coinB = lookup(b.coin); + final fiatA = + a.price.toDouble() * (coinA?.usdPrice?.price?.toDouble() ?? 0.0); + final fiatB = + b.price.toDouble() * (coinB?.usdPrice?.price?.toDouble() ?? 0.0); + return fiatB.compareTo(fiatA); + }); + return result; + } + + legacyPrepare(orders, legacyLookup); + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + prepareOrdersForTable( + context, + orders, + null, + AuthorizeMode.noLogin, + coinLookup: optimisedLookup, + ); + return const SizedBox.shrink(); + }, + ), + ), + ); + + expect(legacyCalls['BTC']! > optimisedCalls['BTC']!, isTrue); + expect(legacyCalls['KMD']! > optimisedCalls['KMD']!, isTrue); + }); + }); +}