Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -29,7 +28,9 @@ class CoinGeckoRepository implements CexRepository {
final IdResolutionStrategy _idResolutionStrategy;
final bool _enableMemoization;

final AsyncMemoizer<List<CexCoin>> _coinListMemoizer = AsyncMemoizer();
/// Populated only after a successful fetch; failures do not poison retries.
List<CexCoin>? _cachedCoinList;
Future<List<CexCoin>>? _coinListInFlight;
Set<String>? _cachedFiatCurrencies;

/// Fetches the CoinGecko market data.
Expand All @@ -48,30 +49,49 @@ class CoinGeckoRepository implements CexRepository {

@override
Future<List<CexCoin>> 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<List<CexCoin>> _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
Expand Down Expand Up @@ -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;
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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 = <String, String>{
'TRX': 'tron',
'TRX-BEP20': 'tron',
};

const _coinPaprikaFallbackIds = <String, String>{
'TRX': 'trx-tron',
'TRX-BEP20': 'trx-tron',
};

String? _fallbackIdForAsset(AssetId assetId, Map<String, String> 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:
Expand Down Expand Up @@ -87,12 +104,14 @@ class CoinGeckoIdResolutionStrategy implements IdResolutionStrategy {
/// be used and an error is thrown in [resolveTradingSymbol].
@override
List<String> 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.',
);
}

Expand Down Expand Up @@ -140,7 +159,9 @@ class CoinPaprikaIdResolutionStrategy implements IdResolutionStrategy {
/// error is thrown in [resolveTradingSymbol].
@override
List<String> getIdPriority(AssetId assetId) {
final coinPaprikaId = assetId.symbol.coinPaprikaId;
final coinPaprikaId =
assetId.symbol.coinPaprikaId ??
_fallbackIdForAsset(assetId, _coinPaprikaFallbackIds);

if (coinPaprikaId == null || coinPaprikaId.isEmpty) {
_logger.fine(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> ensureCacheInitialized(List<CexRepository> repositories) async {
Expand All @@ -63,14 +64,12 @@ class DefaultRepositorySelectionStrategy
required List<CexRepository> availableRepositories,
}) async {
final candidates = <CexRepository>[];
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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -688,7 +726,6 @@ void main() {
),
];

DateTime? capturedStartDate;
DateTime? capturedEndDate;

when(
Expand All @@ -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;
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> supports(
AssetId assetId,
QuoteCurrency fiatCurrency,
PriceRequestType requestType,
) async {
await Future<void>.delayed(delay);
return super.supports(assetId, fiatCurrency, requestType);
}
}

void main() {
group('RepositorySelectionStrategy', () {
late RepositorySelectionStrategy strategy;
Expand Down Expand Up @@ -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', () {
Expand Down
Loading
Loading