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 31f502b2..371a5d20 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 @@ -33,6 +33,12 @@ class CoinGeckoRepository implements CexRepository { Future>? _coinListInFlight; Set? _cachedFiatCurrencies; + /// Tracks when the last coin list fetch failed, to prevent API request spam. + /// When a fetch fails (e.g. due to rate limiting), we avoid retrying until + /// the cooldown period has elapsed. + DateTime? _lastCoinListFailure; + static const _coinListFailureCooldown = Duration(minutes: 5); + /// Fetches the CoinGecko market data. /// /// Returns a list of [CoinMarketData] objects containing the market data. @@ -57,14 +63,35 @@ class CoinGeckoRepository implements CexRepository { if (_cachedCoinList != null) { return _cachedCoinList!; } + + // Prevent API spam: don't retry if we recently failed. + // Without this guard, every price request triggers supports() which calls + // getCoinList(), and each failed call immediately retries the API — causing + // a request storm that exhausts rate limits. + if (_lastCoinListFailure != null) { + final elapsed = DateTime.now().difference(_lastCoinListFailure!); + if (elapsed < _coinListFailureCooldown) { + throw StateError( + 'CoinGecko coin list fetch is in cooldown after a recent failure ' + '(${(_coinListFailureCooldown - elapsed).inSeconds}s remaining)', + ); + } + _lastCoinListFailure = null; + } + if (_coinListInFlight != null) { return _coinListInFlight!; } _coinListInFlight = _fetchCoinListInternal() .then((list) { _cachedCoinList = list; + _lastCoinListFailure = null; return list; }) + .catchError((Object error, StackTrace stackTrace) { + _lastCoinListFailure = DateTime.now(); + Error.throwWithStackTrace(error, stackTrace); + }) .whenComplete(() { _coinListInFlight = null; }); 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 6d01875d..534f26b6 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 @@ -413,6 +413,109 @@ void main() { }); }); + group('getCoinList failure cooldown', () { + test( + 'should not retry API call during cooldown after failure', + () async { + var callCount = 0; + when(() => mockProvider.fetchCoinList()).thenAnswer((_) async { + callCount++; + throw Exception('API error'); + }); + when( + () => mockProvider.fetchSupportedVsCurrencies(), + ).thenAnswer((_) async => ['usd']); + + // Enable memoization for this test + final memoizedRepo = CoinGeckoRepository( + coinGeckoProvider: mockProvider, + ); + + // First call should hit the API and fail + expect(memoizedRepo.getCoinList(), throwsA(isA())); + await Future.delayed(Duration.zero); + expect(callCount, equals(1)); + + // Second call should throw StateError (cooldown) without hitting API + expect(memoizedRepo.getCoinList(), throwsA(isA())); + await Future.delayed(Duration.zero); + expect(callCount, equals(1)); // Still 1 - no new API call + }, + ); + + test( + 'should return false from supports() during cooldown without API call', + () async { + when(() => mockProvider.fetchCoinList()).thenThrow( + Exception('rate limit'), + ); + when( + () => mockProvider.fetchSupportedVsCurrencies(), + ).thenAnswer((_) async => ['usd']); + + final memoizedRepo = CoinGeckoRepository( + coinGeckoProvider: mockProvider, + ); + + final assetId = AssetId( + id: 'bitcoin', + name: 'Bitcoin', + symbol: AssetSymbol(assetConfigId: 'BTC', coinGeckoId: 'bitcoin'), + chainId: AssetChainId(chainId: 0), + derivationPath: null, + subClass: CoinSubClass.utxo, + ); + + // First supports() call triggers the API failure + final result1 = await memoizedRepo.supports( + assetId, + FiatCurrency.usd, + PriceRequestType.currentPrice, + ); + expect(result1, isFalse); + + // Second supports() call should return false without new API call + final result2 = await memoizedRepo.supports( + assetId, + FiatCurrency.usd, + PriceRequestType.currentPrice, + ); + expect(result2, isFalse); + + // Provider should only have been called once + verify(() => mockProvider.fetchCoinList()).called(1); + }, + ); + + test('should cache result on success and not call API again', () async { + when(() => mockProvider.fetchCoinList()).thenAnswer( + (_) async => [ + const CexCoin( + id: 'bitcoin', + symbol: 'btc', + name: 'Bitcoin', + currencies: {}, + ), + ], + ); + when( + () => mockProvider.fetchSupportedVsCurrencies(), + ).thenAnswer((_) async => ['usd']); + + final memoizedRepo = CoinGeckoRepository( + coinGeckoProvider: mockProvider, + ); + + final result1 = await memoizedRepo.getCoinList(); + final result2 = await memoizedRepo.getCoinList(); + + expect(result1.length, equals(1)); + expect(result2.length, equals(1)); + // API should only be called once + verify(() => mockProvider.fetchCoinList()).called(1); + }); + }); + group('_mapFiatCurrencyToCoingecko mapping verification', () { setUp(() { // Mock the coin list response diff --git a/packages/komodo_ui/lib/src/defi/asset/asset_icon.dart b/packages/komodo_ui/lib/src/defi/asset/asset_icon.dart index 48a8d854..1e3c5df1 100644 --- a/packages/komodo_ui/lib/src/defi/asset/asset_icon.dart +++ b/packages/komodo_ui/lib/src/defi/asset/asset_icon.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show AssetManifest, rootBundle; import 'package:komodo_defi_types/komodo_defi_types.dart'; /// A widget that displays an icon for a given [AssetId]. @@ -159,6 +160,10 @@ class _AssetIconResolver extends StatelessWidget { static final Map _assetExistenceCache = {}; static final Map _cdnExistenceCache = {}; static final Map _customIconsCache = {}; + static final Map _lastCdnFailureAt = {}; + static Set? _bundledAssetPaths; + static Future>? _bundledAssetPathsLoader; + static const _cdnRetryInterval = Duration(minutes: 1); static void registerCustomIcon(AssetId assetId, ImageProvider imageProvider) { final sanitizedId = assetId.symbol.configSymbol.toLowerCase(); @@ -169,6 +174,9 @@ class _AssetIconResolver extends StatelessWidget { _assetExistenceCache.clear(); _cdnExistenceCache.clear(); _customIconsCache.clear(); + _lastCdnFailureAt.clear(); + _bundledAssetPaths = null; + _bundledAssetPathsLoader = null; } String get _sanitizedId => @@ -176,19 +184,63 @@ class _AssetIconResolver extends StatelessWidget { String get _imagePath => '$_coinImagesFolder$_sanitizedId.png'; String get _cdnUrl => '$_mediaCdnUrl$_sanitizedId.png'; + static Future> _loadBundledAssetPaths() async { + if (_bundledAssetPaths != null) { + return _bundledAssetPaths!; + } + + if (_bundledAssetPathsLoader != null) { + return _bundledAssetPathsLoader!; + } + + _bundledAssetPathsLoader = () async { + final manifest = await AssetManifest.loadFromAssetBundle(rootBundle); + return manifest.listAssets().toSet(); + }(); + + try { + _bundledAssetPaths = await _bundledAssetPathsLoader; + return _bundledAssetPaths!; + } finally { + _bundledAssetPathsLoader = null; + } + } + + static Future _isBundledAssetDeclared(String assetPath) async { + try { + final bundledPaths = await _loadBundledAssetPaths(); + return bundledPaths.contains(assetPath); + } catch (e) { + debugPrint('Failed to load asset manifest for icon precache: $e'); + return null; + } + } + static Future _didImagePrecacheSucceed( ImageProvider image, BuildContext context, ) async { final outcome = _PrecacheOutcome(); - await precacheImage( - image, - context, - onError: outcome.recordFailure, - ); + await precacheImage(image, context, onError: outcome.recordFailure); return outcome.succeeded; } + static Future _precacheCdnImage( + BuildContext context, + NetworkImage cdnImage, + String sanitizedId, + ) async { + if (!context.mounted) return false; + final cdnSucceeded = await _didImagePrecacheSucceed(cdnImage, context); + _cdnExistenceCache[sanitizedId] = cdnSucceeded; + if (cdnSucceeded) { + _lastCdnFailureAt.remove(sanitizedId); + } else { + _lastCdnFailureAt[sanitizedId] = DateTime.now(); + } + return cdnSucceeded; + } + static Future precacheAssetIcon( BuildContext context, AssetId asset, { @@ -199,42 +251,61 @@ class _AssetIconResolver extends StatelessWidget { try { if (_customIconsCache.containsKey(sanitizedId)) { - if (context.mounted) { - await precacheImage( - _customIconsCache[sanitizedId]!, - context, - onError: (e, stackTrace) { - if (throwExceptions) { - throw Exception( - 'Failed to pre-cache custom image for coin $asset: $e', - ); - } - }, - ); + if (!context.mounted) return; + + final customSucceeded = await _didImagePrecacheSucceed( + _customIconsCache[sanitizedId]!, + context, + ); + if (throwExceptions && !customSucceeded) { + throw Exception('Failed to pre-cache custom image for coin $asset.'); } return; } final assetImage = AssetImage(resolver._imagePath); final cdnImage = NetworkImage(resolver._cdnUrl); + final bundledAssetExists = await _isBundledAssetDeclared( + resolver._imagePath, + ); - final assetSucceeded = await _didImagePrecacheSucceed(assetImage, context); - _assetExistenceCache[resolver._imagePath] = assetSucceeded; + if (bundledAssetExists == true || bundledAssetExists == null) { + if (!context.mounted) return; + final assetSucceeded = await _didImagePrecacheSucceed( + assetImage, + context, + ); + _assetExistenceCache[resolver._imagePath] = assetSucceeded; + if (assetSucceeded) { + _cdnExistenceCache.remove(sanitizedId); + _lastCdnFailureAt.remove(sanitizedId); + return; + } - if (assetSucceeded) { + _assetExistenceCache[resolver._imagePath] = false; + if (!context.mounted) return; + final cdnSucceeded = await _precacheCdnImage( + context, + cdnImage, + sanitizedId, + ); + if (throwExceptions && !cdnSucceeded) { + throw Exception( + 'Failed to pre-cache bundled and CDN images for asset ${asset.id}', + ); + } return; } - final cdnSucceeded = - context.mounted && await _didImagePrecacheSucceed(cdnImage, context); - if (context.mounted) { - _cdnExistenceCache[sanitizedId] = cdnSucceeded; - } - + _assetExistenceCache[resolver._imagePath] = false; + if (!context.mounted) return; + final cdnSucceeded = await _precacheCdnImage( + context, + cdnImage, + sanitizedId, + ); if (throwExceptions && !cdnSucceeded) { - throw Exception( - 'Failed to pre-cache bundled and CDN images for asset ${asset.id}', - ); + throw Exception('Failed to pre-cache CDN image for asset ${asset.id}'); } } catch (e) { debugPrint('Error in precacheAssetIcon for ${asset.id}: $e'); @@ -247,6 +318,28 @@ class _AssetIconResolver extends StatelessWidget { return _assetExistenceCache[resolver._imagePath] ?? false; } + Widget _buildFallbackIcon() { + return Icon(Icons.monetization_on_outlined, size: size); + } + + Widget _buildCdnImage() { + return Image.network( + _cdnUrl, + filterQuality: FilterQuality.high, + errorBuilder: (context, error, stackTrace) { + _cdnExistenceCache[_sanitizedId] = false; + _lastCdnFailureAt[_sanitizedId] = DateTime.now(); + return _buildFallbackIcon(); + }, + ); + } + + bool _shouldRetryCdnNow() { + final lastFailure = _lastCdnFailureAt[_sanitizedId]; + if (lastFailure == null) return true; + return DateTime.now().difference(lastFailure) >= _cdnRetryInterval; + } + @override Widget build(BuildContext context) { if (_customIconsCache.containsKey(_sanitizedId)) { @@ -260,22 +353,34 @@ class _AssetIconResolver extends StatelessWidget { ); } - _assetExistenceCache[_imagePath] = true; + final bundledState = _assetExistenceCache[_imagePath]; + final cdnState = _cdnExistenceCache[_sanitizedId]; + + if (bundledState == false && cdnState == true) { + return _buildCdnImage(); + } + + if (bundledState == false && cdnState == false) { + if (_shouldRetryCdnNow()) { + _cdnExistenceCache[_sanitizedId] = true; + return _buildCdnImage(); + } + return _buildFallbackIcon(); + } + + _assetExistenceCache[_imagePath] = bundledState ?? true; return Image.asset( _imagePath, filterQuality: FilterQuality.high, errorBuilder: (context, error, stackTrace) { _assetExistenceCache[_imagePath] = false; + if (_cdnExistenceCache[_sanitizedId] == false) { + return _buildFallbackIcon(); + } + _cdnExistenceCache[_sanitizedId] ??= true; - return Image.network( - _cdnUrl, - filterQuality: FilterQuality.high, - errorBuilder: (context, error, stackTrace) { - _cdnExistenceCache[_sanitizedId] = false; - return Icon(Icons.monetization_on_outlined, size: size); - }, - ); + return _buildCdnImage(); }, ); }