diff --git a/lib/bloc/bridge_form/bridge_bloc.dart b/lib/bloc/bridge_form/bridge_bloc.dart index 354dee5825..0ed6c8efaf 100644 --- a/lib/bloc/bridge_form/bridge_bloc.dart +++ b/lib/bloc/bridge_form/bridge_bloc.dart @@ -249,7 +249,7 @@ class BridgeBloc extends Bloc { return; } - _autoActivateCoin(event.coin.abbr); + await _autoActivateCoin(event.coin.abbr); _subscribeMaxSellAmount(); add(const BridgeGetMinSellAmount()); diff --git a/lib/bloc/coins_bloc/coins_bloc.dart b/lib/bloc/coins_bloc/coins_bloc.dart index 8721b25cf6..7ed5373e60 100644 --- a/lib/bloc/coins_bloc/coins_bloc.dart +++ b/lib/bloc/coins_bloc/coins_bloc.dart @@ -355,6 +355,8 @@ class CoinsBloc extends Bloc { Emitter emit, ) async { _isInitialActivationInProgress = true; + // Inform repo to gate auto-activations from other features + _coinsRepo.markInitialActivationStart(); try { // Ensure any cached addresses/pubkeys from a previous wallet are cleared // so that UI fetches fresh pubkeys for the newly logged-in wallet. @@ -383,10 +385,12 @@ class CoinsBloc extends Bloc { _log.shout('Error during initial coin activation', e, s); } finally { _isInitialActivationInProgress = false; + _coinsRepo.markInitialActivationComplete(); } }()); } catch (e, s) { _isInitialActivationInProgress = false; + _coinsRepo.markInitialActivationComplete(); _log.shout('Error on login', e, s); } } @@ -502,6 +506,7 @@ class CoinsBloc extends Bloc { void _resetInitialActivationState() { _isInitialActivationInProgress = false; + _coinsRepo.markInitialActivationComplete(); } Future _activateCoins( diff --git a/lib/bloc/coins_bloc/coins_repo.dart b/lib/bloc/coins_bloc/coins_repo.dart index 9907caacca..74985c43f3 100644 --- a/lib/bloc/coins_bloc/coins_repo.dart +++ b/lib/bloc/coins_bloc/coins_repo.dart @@ -82,6 +82,74 @@ class CoinsRepo { // Map to keep track of active balance watchers final Map> _balanceWatchers = {}; + /// Tracks in-flight activations per asset to prevent duplicate concurrent activations + final Map> _activationsInFlight = {}; + + /// Initial activation gate: used by UI flows to wait until initial wallet + /// activation completes in CoinsBloc before firing additional activations. + bool _initialActivationInProgress = false; + Completer? _initialActivationCompleter; + + /// Mark start of the initial activation phase (called by CoinsBloc) + void markInitialActivationStart() { + _initialActivationInProgress = true; + if (_initialActivationCompleter == null || + (_initialActivationCompleter?.isCompleted ?? true)) { + _initialActivationCompleter = Completer(); + } + } + + /// Mark completion of the initial activation phase (called by CoinsBloc) + void markInitialActivationComplete() { + _initialActivationInProgress = false; + final completer = _initialActivationCompleter; + if (completer != null && !completer.isCompleted) { + completer.complete(); + } + } + + /// Await completion of the initial activation phase. + /// If not in progress, returns immediately. If in progress and a timeout is + /// provided, waits up to the timeout, otherwise waits until completion. + Future waitForInitialActivationToComplete({ + Duration? timeout, + }) async { + if (!_initialActivationInProgress) return; + final completer = _initialActivationCompleter; + if (completer == null) return; + try { + if (timeout == null) { + await completer.future; + } else { + await completer.future.timeout(timeout); + } + } catch (_) { + // Swallow timeout/errors; callers should proceed even if gate didn’t finish + } + } + + Future _waitForCoinAvailability( + AssetId assetId, { + int maxRetries = 5, + Duration baseDelay = const Duration(milliseconds: 600), + Duration maxDelay = const Duration(milliseconds: 3000), + }) async { + for (int attempt = 0; attempt < maxRetries; attempt++) { + try { + final activated = await _kdfSdk.activatedAssetsCache + .getActivatedAssetIds(forceRefresh: true); + if (activated.contains(assetId)) return true; + } catch (_) {} + + if (attempt < maxRetries - 1) { + final delayMs = (baseDelay.inMilliseconds * (1 << attempt)) + .clamp(baseDelay.inMilliseconds, maxDelay.inMilliseconds); + await Future.delayed(Duration(milliseconds: delayMs)); + } + } + return false; + } + /// Hack used to broadcast activated/deactivated coins to the CoinsBloc to /// update the status of the coins in the UI layer. This is needed as there /// are direct references to [CoinsRepo] that activate/deactivate coins @@ -286,6 +354,11 @@ class CoinsRepo { /// return a zero balance. Future tryGetBalanceInfo(AssetId coinId) async { try { + // Avoid triggering backend wallet-balance tasks for inactive assets. + final isActive = await isAssetActivated(coinId); + if (!isActive) { + return kdf_rpc.BalanceInfo.zero(); + } final balanceInfo = await _kdfSdk.balances.getBalance(coinId); return balanceInfo; } catch (e, s) { @@ -404,29 +477,51 @@ class CoinsRepo { 'Asset ${asset.id.id} is already activated. Skipping activation.', ); } else { - if (notifyListeners) { - _broadcastAsset(coin.copyWith(state: CoinState.activating)); - } + // Deduplicate concurrent activations for the same asset + final existing = _activationsInFlight[asset.id]; + if (existing != null) { + _log.info( + 'Activation already in progress for ${asset.id.id}. Joining existing task.', + ); + await existing; + } else { + if (notifyListeners) { + _broadcastAsset(coin.copyWith(state: CoinState.activating)); + } - // Use retry with exponential backoff for activation - await retry( - () async { - final progress = await _kdfSdk.assets.activateAsset(asset).last; - if (!progress.isSuccess) { - throw Exception( - progress.errorMessage ?? - 'Activation failed for ${asset.id.id}', - ); + // Start activation once and track in-flight future. + // Avoid over-eager retries that can create duplicate backend tasks. + final activationFuture = (() async { + try { + final progress = + await _kdfSdk.assets.activateAsset(asset).last; + if (!progress.isSuccess) { + throw Exception( + progress.errorMessage ?? + 'Activation failed for ${asset.id.id}', + ); + } + } catch (e) { + final err = e.toString(); + final maybeIdempotentStorageError = err.contains('ConstraintError') || + err.contains("wallet_account_id") || + err.contains('Error saving HD account to storage'); + if (maybeIdempotentStorageError) { + // Treat as idempotent: wait briefly for coin to appear instead of re-initiating. + final becameActive = await _waitForCoinAvailability(asset.id); + if (becameActive) return; + } + rethrow; } - }, - maxAttempts: maxRetryAttempts, - backoffStrategy: ExponentialBackoff( - initialDelay: initialRetryDelay, - maxDelay: maxRetryDelay, - ), - ); + })().whenComplete(() { + _activationsInFlight.remove(asset.id); + }); + + _activationsInFlight[asset.id] = activationFuture; + await activationFuture; - _log.info('Asset activated: ${asset.id.id}'); + _log.info('Asset activated: ${asset.id.id}'); + } } if (kDebugElectrumLogs) { _log.info( diff --git a/lib/bloc/fiat/fiat_onramp_form/fiat_form_bloc.dart b/lib/bloc/fiat/fiat_onramp_form/fiat_form_bloc.dart index 8abbf5f35f..c1628ffc36 100644 --- a/lib/bloc/fiat/fiat_onramp_form/fiat_form_bloc.dart +++ b/lib/bloc/fiat/fiat_onramp_form/fiat_form_bloc.dart @@ -121,6 +121,10 @@ class FiatFormBloc extends Bloc { // Activate the asset via CoinsRepo to ensure broadcasts reach CoinsBloc final asset = event.selectedCoin.toAsset(_sdk); + // Gate until initial activation completes to avoid duplicate tasks + await _coinsRepo.waitForInitialActivationToComplete( + timeout: const Duration(seconds: 30), + ); await _coinsRepo.activateAssetsSync([asset]); // TODO: increase the max delay in the SDK or make it adjustable AssetPubkeys? assetPubkeys = _sdk.pubkeys.lastKnown(asset.id); diff --git a/lib/bloc/market_maker_bot/market_maker_trade_form/market_maker_trade_form_bloc.dart b/lib/bloc/market_maker_bot/market_maker_trade_form/market_maker_trade_form_bloc.dart index 9c73dcf4b2..be86fe3137 100644 --- a/lib/bloc/market_maker_bot/market_maker_trade_form/market_maker_trade_form_bloc.dart +++ b/lib/bloc/market_maker_bot/market_maker_trade_form/market_maker_trade_form_bloc.dart @@ -83,6 +83,14 @@ class MarketMakerTradeFormBloc Emitter emit, ) async { final identicalBuyAndSellCoins = state.buyCoin.value == event.sellCoin; + final sellCoin = event.sellCoin?.id; + double sellCoinBalance = 0; + + if (sellCoin != null) { + // Use repo guard to avoid SDK calls if inactive + sellCoinBalance = + (await _coinsRepo.tryGetBalanceInfo(sellCoin)).spendable.toDouble(); + } // Emit immediately with new coin selection for fast UI update emit( @@ -645,6 +653,11 @@ class MarketMakerTradeFormBloc return; } + // Gate auto-activations until initial activation completes + await _coinsRepo.waitForInitialActivationToComplete( + timeout: const Duration(seconds: 30), + ); + if (!coin.isActive) { await _coinsRepo.activateCoinsSync([coin]); } else { diff --git a/lib/views/dex/dex_helpers.dart b/lib/views/dex/dex_helpers.dart index a0bb7c2b83..0033d88add 100644 --- a/lib/views/dex/dex_helpers.dart +++ b/lib/views/dex/dex_helpers.dart @@ -232,6 +232,10 @@ Future> activateCoinIfNeeded( if (coin == null) return errors; try { + // Gate auto-activation until CoinsBloc completes initial activation + await coinsRepository.waitForInitialActivationToComplete( + timeout: const Duration(seconds: 30), + ); // sdk handles parent activation logic, so simply call // activation here await coinsRepository.activateCoinsSync([coin]);