diff --git a/packages/komodo_cex_market_data/lib/src/coingecko/data/coingecko_repository.dart b/packages/komodo_cex_market_data/lib/src/coingecko/data/coingecko_repository.dart index 8694f8ce4..31f502b2c 100644 --- a/packages/komodo_cex_market_data/lib/src/coingecko/data/coingecko_repository.dart +++ b/packages/komodo_cex_market_data/lib/src/coingecko/data/coingecko_repository.dart @@ -1,4 +1,3 @@ -import 'package:async/async.dart'; import 'package:decimal/decimal.dart'; import 'package:komodo_cex_market_data/src/cex_repository.dart'; import 'package:komodo_cex_market_data/src/coingecko/_coingecko_index.dart'; @@ -29,7 +28,9 @@ class CoinGeckoRepository implements CexRepository { final IdResolutionStrategy _idResolutionStrategy; final bool _enableMemoization; - final AsyncMemoizer> _coinListMemoizer = AsyncMemoizer(); + /// Populated only after a successful fetch; failures do not poison retries. + List? _cachedCoinList; + Future>? _coinListInFlight; Set? _cachedFiatCurrencies; /// Fetches the CoinGecko market data. @@ -48,30 +49,49 @@ class CoinGeckoRepository implements CexRepository { @override Future> getCoinList() async { - if (_enableMemoization) { - return _coinListMemoizer.runOnce(_fetchCoinListInternal); - } else { + if (!_enableMemoization) { // Warning: Direct API calls without memoization can lead to API rate limiting // and unnecessary network requests. Use this mode sparingly. return _fetchCoinListInternal(); } + if (_cachedCoinList != null) { + return _cachedCoinList!; + } + if (_coinListInFlight != null) { + return _coinListInFlight!; + } + _coinListInFlight = _fetchCoinListInternal() + .then((list) { + _cachedCoinList = list; + return list; + }) + .whenComplete(() { + _coinListInFlight = null; + }); + return _coinListInFlight!; } /// Internal method to fetch coin list data from the API. Future> _fetchCoinListInternal() async { - final coins = await coinGeckoProvider.fetchCoinList(); - final supportedCurrencies = await coinGeckoProvider - .fetchSupportedVsCurrencies(); - - final result = coins - .map((CexCoin e) => e.copyWith(currencies: supportedCurrencies.toSet())) - .toSet(); - - _cachedFiatCurrencies = supportedCurrencies - .map((s) => s.toUpperCase()) - .toSet(); - - return result.toList(); + try { + final coins = await coinGeckoProvider.fetchCoinList(); + final supportedCurrencies = await coinGeckoProvider + .fetchSupportedVsCurrencies(); + + final result = coins + .map( + (CexCoin e) => e.copyWith(currencies: supportedCurrencies.toSet()), + ) + .toSet(); + + _cachedFiatCurrencies = supportedCurrencies + .map((s) => s.toUpperCase()) + .toSet(); + + return result.toList(); + } catch (e, st) { + Error.throwWithStackTrace(e, st); + } } @override @@ -305,6 +325,10 @@ class CoinGeckoRepository implements CexRepository { } on ArgumentError { // If we cannot resolve a trading symbol, treat as unsupported return false; + } catch (_) { + // Coin list / network failures: treat as unsupported so fallback repos run + // without throwing from [DefaultRepositorySelectionStrategy]. + return false; } } diff --git a/packages/komodo_cex_market_data/lib/src/id_resolution_strategy.dart b/packages/komodo_cex_market_data/lib/src/id_resolution_strategy.dart index 11b857958..4334d58e1 100644 --- a/packages/komodo_cex_market_data/lib/src/id_resolution_strategy.dart +++ b/packages/komodo_cex_market_data/lib/src/id_resolution_strategy.dart @@ -1,6 +1,23 @@ +import 'package:collection/collection.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:logging/logging.dart'; +const _coinGeckoFallbackIds = { + 'TRX': 'tron', + 'TRX-BEP20': 'tron', +}; + +const _coinPaprikaFallbackIds = { + 'TRX': 'trx-tron', + 'TRX-BEP20': 'trx-tron', +}; + +String? _fallbackIdForAsset(AssetId assetId, Map aliases) { + return {assetId.id.toUpperCase(), assetId.symbol.configSymbol.toUpperCase()} + .map((key) => aliases[key]) + .firstWhereOrNull((alias) => alias?.isNotEmpty ?? false); +} + /// Strategy for resolving platform-specific asset identifiers /// /// Exceptions: @@ -87,12 +104,14 @@ class CoinGeckoIdResolutionStrategy implements IdResolutionStrategy { /// be used and an error is thrown in [resolveTradingSymbol]. @override List getIdPriority(AssetId assetId) { - final coinGeckoId = assetId.symbol.coinGeckoId; + final coinGeckoId = + assetId.symbol.coinGeckoId ?? + _fallbackIdForAsset(assetId, _coinGeckoFallbackIds); if (coinGeckoId == null || coinGeckoId.isEmpty) { _logger.fine( - 'Missing coinGeckoId for asset ${assetId.symbol.configSymbol}, ' - 'falling back to configSymbol. This may cause API issues.', + 'Missing coinGeckoId for asset ${assetId.symbol.configSymbol}. ' + 'CoinGecko API cannot be used for this asset.', ); } @@ -140,7 +159,9 @@ class CoinPaprikaIdResolutionStrategy implements IdResolutionStrategy { /// error is thrown in [resolveTradingSymbol]. @override List getIdPriority(AssetId assetId) { - final coinPaprikaId = assetId.symbol.coinPaprikaId; + final coinPaprikaId = + assetId.symbol.coinPaprikaId ?? + _fallbackIdForAsset(assetId, _coinPaprikaFallbackIds); if (coinPaprikaId == null || coinPaprikaId.isEmpty) { _logger.fine( diff --git a/packages/komodo_cex_market_data/lib/src/repository_selection_strategy.dart b/packages/komodo_cex_market_data/lib/src/repository_selection_strategy.dart index 0cc3b660f..d70b6e232 100644 --- a/packages/komodo_cex_market_data/lib/src/repository_selection_strategy.dart +++ b/packages/komodo_cex_market_data/lib/src/repository_selection_strategy.dart @@ -48,6 +48,7 @@ abstract class RepositorySelectionStrategy { class DefaultRepositorySelectionStrategy implements RepositorySelectionStrategy { static final Logger _logger = Logger('DefaultRepositorySelectionStrategy'); + static const _supportsTimeout = Duration(seconds: 5); @override Future ensureCacheInitialized(List repositories) async { @@ -63,14 +64,12 @@ class DefaultRepositorySelectionStrategy required List availableRepositories, }) async { final candidates = []; - const timeout = Duration(seconds: 2); - await Future.wait( availableRepositories.map((repo) async { try { final isSupported = await repo .supports(assetId, fiatCurrency, requestType) - .timeout(timeout, onTimeout: () => false); + .timeout(_supportsTimeout, onTimeout: () => false); if (isSupported) { candidates.add(repo); } diff --git a/packages/komodo_cex_market_data/test/coingecko/coingecko_repository_test.dart b/packages/komodo_cex_market_data/test/coingecko/coingecko_repository_test.dart index 55f415403..6d01875dc 100644 --- a/packages/komodo_cex_market_data/test/coingecko/coingecko_repository_test.dart +++ b/packages/komodo_cex_market_data/test/coingecko/coingecko_repository_test.dart @@ -246,6 +246,36 @@ void main() { } }); + test('should resolve TRX via fallback CoinGecko id', () async { + when(() => mockProvider.fetchCoinList()).thenAnswer( + (_) async => [ + const CexCoin( + id: 'tron', + symbol: 'trx', + name: 'TRON', + currencies: {}, + ), + ], + ); + + final assetId = AssetId( + id: 'TRX', + name: 'TRON', + symbol: AssetSymbol(assetConfigId: 'TRX'), + chainId: AssetChainId(chainId: 0), + derivationPath: null, + subClass: CoinSubClass.utxo, + ); + + final supports = await repository.supports( + assetId, + Stablecoin.usdt, + PriceRequestType.priceHistory, + ); + + expect(supports, isTrue); + }); + test('should support EUR-pegged stablecoins via EUR mapping', () async { final assetId = AssetId( id: 'bitcoin', diff --git a/packages/komodo_cex_market_data/test/coinpaprika/coinpaprika_repository_test.dart b/packages/komodo_cex_market_data/test/coinpaprika/coinpaprika_repository_test.dart index 7adfe5e5d..8efd5a5d2 100644 --- a/packages/komodo_cex_market_data/test/coinpaprika/coinpaprika_repository_test.dart +++ b/packages/komodo_cex_market_data/test/coinpaprika/coinpaprika_repository_test.dart @@ -393,6 +393,44 @@ void main() { }, ); + test('returns true for TRX via fallback CoinPaprika id', () async { + when( + () => mockProvider.supportedQuoteCurrencies, + ).thenReturn([FiatCurrency.usd, FiatCurrency.eur]); + + MockHelpers.setupProviderCoinListResponse( + mockProvider, + coins: const [ + CoinPaprikaCoin( + id: 'trx-tron', + name: 'TRON', + symbol: 'TRX', + rank: 1, + isNew: false, + isActive: true, + type: 'coin', + ), + ], + ); + + final trxAsset = AssetId( + id: 'TRX', + name: 'TRON', + symbol: AssetSymbol(assetConfigId: 'TRX'), + chainId: AssetChainId(chainId: 0), + derivationPath: null, + subClass: CoinSubClass.utxo, + ); + + final result = await repository.supports( + trxAsset, + Stablecoin.usdt, + PriceRequestType.priceHistory, + ); + + expect(result, isTrue); + }); + test('returns false for unsupported quote currency', () async { // Arrange MockHelpers.setupProviderCoinListResponse( @@ -688,7 +726,6 @@ void main() { ), ]; - DateTime? capturedStartDate; DateTime? capturedEndDate; when( @@ -700,8 +737,6 @@ void main() { interval: any(named: 'interval'), ), ).thenAnswer((invocation) async { - capturedStartDate = - invocation.namedArguments[#startDate] as DateTime?; capturedEndDate = invocation.namedArguments[#endDate] as DateTime?; return mockOhlcData; }); diff --git a/packages/komodo_cex_market_data/test/repository_selection_strategy_test.dart b/packages/komodo_cex_market_data/test/repository_selection_strategy_test.dart index 0135406e9..13734fce8 100644 --- a/packages/komodo_cex_market_data/test/repository_selection_strategy_test.dart +++ b/packages/komodo_cex_market_data/test/repository_selection_strategy_test.dart @@ -184,6 +184,22 @@ class MockFailingRepository implements CexRepository { String toString() => 'MockFailingRepository'; } +class MockSlowSupportingRepository extends MockSupportingRepository { + MockSlowSupportingRepository(super.name, {required this.delay}); + + final Duration delay; + + @override + Future supports( + AssetId assetId, + QuoteCurrency fiatCurrency, + PriceRequestType requestType, + ) async { + await Future.delayed(delay); + return super.supports(assetId, fiatCurrency, requestType); + } +} + void main() { group('RepositorySelectionStrategy', () { late RepositorySelectionStrategy strategy; @@ -279,6 +295,31 @@ void main() { expect(repo, equals(supportingRepo)); }); + + test('allows slower support checks within timeout budget', () async { + final slowRepo = MockSlowSupportingRepository( + 'slow-supporting', + delay: const Duration(seconds: 3), + ); + + final asset = AssetId( + id: 'TRX', + name: 'TRON', + symbol: AssetSymbol(assetConfigId: 'TRX'), + chainId: AssetChainId(chainId: 0), + derivationPath: null, + subClass: CoinSubClass.utxo, + ); + + final repo = await strategy.selectRepository( + assetId: asset, + fiatCurrency: Stablecoin.usdt, + requestType: PriceRequestType.priceHistory, + availableRepositories: [slowRepo], + ); + + expect(repo, equals(slowRepo)); + }); }); group('mapped quote currency support', () { diff --git a/packages/komodo_defi_sdk/lib/src/activation/activation_manager.dart b/packages/komodo_defi_sdk/lib/src/activation/activation_manager.dart index fd061e571..21d81b5e8 100644 --- a/packages/komodo_defi_sdk/lib/src/activation/activation_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/activation/activation_manager.dart @@ -114,12 +114,27 @@ class ActivationManager { continue; } - // Register activation attempt - final primaryCompleter = await _registerActivation(group.primary.id); - if (primaryCompleter == null) { + // Register activation attempt. + final registration = await _registerActivation(group.primary.id); + final primaryCompleter = registration.completer; + if (!registration.shouldStartActivation) { debugPrint( 'Activation already in progress for ${group.primary.id.name}', ); + try { + await primaryCompleter.future; + yield ActivationProgress.alreadyActiveSuccess( + assetName: group.primary.id.name, + childCount: group.children?.length ?? 0, + ); + } catch (e, st) { + final mappedError = _mapError(e, group.primary.id); + yield ActivationProgress.error( + message: mappedError.fallbackMessage, + sdkError: mappedError, + stackTrace: st, + ); + } continue; } @@ -183,13 +198,26 @@ class ActivationManager { await _handleActivationComplete(group, progress, primaryCompleter); } } - } catch (e) { + } catch (e, st) { + final recoveredProgress = await _tryRecoverAlreadyActivated(group, e); + if (recoveredProgress != null) { + if (!primaryCompleter.isCompleted) { + primaryCompleter.complete(); + } + yield recoveredProgress; + continue; + } + debugPrint('Activation failed: $e'); final mappedError = _mapError(e, group.primary.id); if (!primaryCompleter.isCompleted) { primaryCompleter.completeError(mappedError); } - throw mappedError; + yield ActivationProgress.error( + message: mappedError.fallbackMessage, + sdkError: mappedError, + stackTrace: st, + ); } finally { try { await _cleanupActivation(group.primary.id); @@ -224,12 +252,16 @@ class ActivationManager { ); } - /// Check if asset and its children are already activated - Future _checkActivationStatus(_AssetGroup group) async { + /// Check if asset and its children are already activated. + Future _checkActivationStatus( + _AssetGroup group, { + bool forceRefresh = false, + }) async { try { // Use cache instead of direct RPC call to avoid excessive requests - final enabledAssetIds = await _activatedAssetsCache - .getActivatedAssetIds(); + final enabledAssetIds = await _activatedAssetsCache.getActivatedAssetIds( + forceRefresh: forceRefresh, + ); final isActive = enabledAssetIds.contains(group.primary.id); final childrenActive = @@ -257,21 +289,49 @@ class ActivationManager { ); } - /// Register new activation attempt - Future?> _registerActivation(AssetId assetId) async { + /// Register a new activation attempt or join an existing one. + Future<_ActivationRegistration> _registerActivation(AssetId assetId) async { return _protectedOperation(() async { - // Return the existing completer if activation is already in progress - // This ensures subsequent callers properly wait for the activation to complete - if (_activationCompleters.containsKey(assetId)) { - return _activationCompleters[assetId]; + final existingCompleter = _activationCompleters[assetId]; + if (existingCompleter != null) { + return _ActivationRegistration( + completer: existingCompleter, + shouldStartActivation: false, + ); } final completer = Completer(); _activationCompleters[assetId] = completer; - return completer; + return _ActivationRegistration( + completer: completer, + shouldStartActivation: true, + ); }); } + Future _tryRecoverAlreadyActivated( + _AssetGroup group, + Object error, + ) async { + if (!_isAlreadyActivatedError(error)) { + return null; + } + + _activatedAssetsCache.invalidate(); + final refreshedStatus = await _checkActivationStatus( + group, + forceRefresh: true, + ); + return refreshedStatus.isComplete ? refreshedStatus : null; + } + + bool _isAlreadyActivatedError(Object error) { + final message = error.toString(); + return message.contains('PlatformIsAlreadyActivated') || + message.contains('CoinIsAlreadyActivated') || + message.contains('activated already'); + } + /// Handle completion of activation Future _handleActivationComplete( _AssetGroup group, @@ -415,3 +475,13 @@ class _AssetGroup { return groups.values.toList(); } } + +class _ActivationRegistration { + const _ActivationRegistration({ + required this.completer, + required this.shouldStartActivation, + }); + + final Completer completer; + final bool shouldStartActivation; +} diff --git a/packages/komodo_defi_sdk/lib/src/komodo_defi_sdk.dart b/packages/komodo_defi_sdk/lib/src/komodo_defi_sdk.dart index 493d868d0..949738faf 100644 --- a/packages/komodo_defi_sdk/lib/src/komodo_defi_sdk.dart +++ b/packages/komodo_defi_sdk/lib/src/komodo_defi_sdk.dart @@ -207,6 +207,19 @@ class KomodoDefiSdk with SecureRpcPasswordMixin { /// Throws [StateError] if accessed before initialization. AssetManager get assets => _assertSdkInitialized(_container()); + /// Activates an asset through the shared activation coordinator. + /// + /// This is the preferred path for app code that wants to ensure an asset is + /// enabled without racing other managers that may be activating the same + /// asset concurrently. + Future ensureAssetActivated(Asset asset) async { + final coordinator = _assertSdkInitialized( + _container(), + ); + final result = await coordinator.activateAsset(asset); + return result.isSuccess; + } + /// Deletes a persisted custom token from SDK-managed storage. /// /// This removes the token from the custom-token store and the in-memory