Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/bloc/bridge_form/bridge_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ class BridgeBloc extends Bloc<BridgeEvent, BridgeState> {
return;
}

_autoActivateCoin(event.coin.abbr);
await _autoActivateCoin(event.coin.abbr);
_subscribeMaxSellAmount();

add(const BridgeGetMinSellAmount());
Expand Down
5 changes: 5 additions & 0 deletions lib/bloc/coins_bloc/coins_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,8 @@ class CoinsBloc extends Bloc<CoinsEvent, CoinsState> {
Emitter<CoinsState> 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.
Expand Down Expand Up @@ -383,10 +385,12 @@ class CoinsBloc extends Bloc<CoinsEvent, CoinsState> {
_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);
}
}
Expand Down Expand Up @@ -502,6 +506,7 @@ class CoinsBloc extends Bloc<CoinsEvent, CoinsState> {

void _resetInitialActivationState() {
_isInitialActivationInProgress = false;
_coinsRepo.markInitialActivationComplete();
}

Future<void> _activateCoins(
Expand Down
135 changes: 115 additions & 20 deletions lib/bloc/coins_bloc/coins_repo.dart
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,74 @@ class CoinsRepo {
// Map to keep track of active balance watchers
final Map<AssetId, StreamSubscription<BalanceInfo>> _balanceWatchers = {};

/// Tracks in-flight activations per asset to prevent duplicate concurrent activations
final Map<AssetId, Future<void>> _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<void>? _initialActivationCompleter;

/// Mark start of the initial activation phase (called by CoinsBloc)
void markInitialActivationStart() {
_initialActivationInProgress = true;
if (_initialActivationCompleter == null ||
(_initialActivationCompleter?.isCompleted ?? true)) {
_initialActivationCompleter = Completer<void>();
}
}

/// 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<void> 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<bool> _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<void>.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
Expand Down Expand Up @@ -286,6 +354,11 @@ class CoinsRepo {
/// return a zero balance.
Future<kdf_rpc.BalanceInfo> 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) {
Expand Down Expand Up @@ -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<void>(
() 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(
Expand Down
4 changes: 4 additions & 0 deletions lib/bloc/fiat/fiat_onramp_form/fiat_form_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@ class FiatFormBloc extends Bloc<FiatFormEvent, FiatFormState> {

// 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,14 @@ class MarketMakerTradeFormBloc
Emitter<MarketMakerTradeFormState> 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(
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions lib/views/dex/dex_helpers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,10 @@ Future<List<DexFormError>> 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]);
Expand Down
Loading