diff --git a/mobile-app/lib/v2/components/token_icon.dart b/mobile-app/lib/v2/components/token_icon.dart index 0753ecad..7a06c404 100644 --- a/mobile-app/lib/v2/components/token_icon.dart +++ b/mobile-app/lib/v2/components/token_icon.dart @@ -15,8 +15,6 @@ class TokenIcon extends StatelessWidget { Widget build(BuildContext context) { final colors = context.colorsV3; final text = context.themeTextV3; - final iconUrl = token.iconUrl; - final networkIconUrl = token.networkIconUrl; if (token.isQuantus) { return Container( @@ -34,28 +32,14 @@ class TokenIcon extends StatelessWidget { child: Stack( clipBehavior: Clip.none, children: [ - Positioned.fill( - child: ClipOval( - child: iconUrl != null - ? Image.network(iconUrl, fit: BoxFit.cover, errorBuilder: (_, _, _) => _fallback(token, colors, text)) - : _fallback(token, colors, text), - ), - ), + Positioned.fill(child: _monogram(token, colors, text)), Positioned( right: -2, bottom: -2, child: SizedBox( width: networkBadgeSize, height: networkBadgeSize, - child: ClipOval( - child: networkIconUrl != null - ? Image.network( - networkIconUrl, - fit: BoxFit.cover, - errorBuilder: (_, _, _) => _networkFallback(token, colors, text), - ) - : _networkFallback(token, colors, text), - ), + child: _networkMonogram(token, colors, text), ), ), ], @@ -63,7 +47,7 @@ class TokenIcon extends StatelessWidget { ); } - Widget _fallback(SwapToken token, AppColorsV3 colors, AppTextThemeV3 text) { + Widget _monogram(SwapToken token, AppColorsV3 colors, AppTextThemeV3 text) { return Container( decoration: BoxDecoration( color: colors.bgSurface2, @@ -81,7 +65,7 @@ class TokenIcon extends StatelessWidget { ); } - Widget _networkFallback(SwapToken token, AppColorsV3 colors, AppTextThemeV3 text) { + Widget _networkMonogram(SwapToken token, AppColorsV3 colors, AppTextThemeV3 text) { return Container( decoration: BoxDecoration( color: colors.bgVoid, diff --git a/mobile-app/lib/v2/screens/swap/swap_screen.dart b/mobile-app/lib/v2/screens/swap/swap_screen.dart index 7180d22e..26abeeac 100644 --- a/mobile-app/lib/v2/screens/swap/swap_screen.dart +++ b/mobile-app/lib/v2/screens/swap/swap_screen.dart @@ -74,7 +74,7 @@ class _SwapScreenState extends ConsumerState { final quantus = await service.getListedQuantusToken(); if (!mounted || load != _loads) return; setState(() { - _foreign ??= tokens.first; + _foreign ??= tokens.firstWhere((t) => !t.isQuantus); _quantus = quantus; }); } catch (e) { @@ -95,18 +95,18 @@ class _SwapScreenState extends ConsumerState { _amountController.clear(); } - /// Picks the external token. Picking from the QTC side moves QTC to the other side. + /// Picks a side's token. QTC heads the list: picking it for the other side + /// moves QTC there, as does picking a foreign token for QTC's side. Future _pickToken(SwapToken tapped) async { final service = ref.read(swapServiceProvider); - final foreign = _foreign!; final token = await showTokenPickerSheet( context, - current: foreign, + current: tapped, loadTokens: ({bool forceRefresh = false}) => service.getFromTokens(limit: 10, forceRefresh: forceRefresh), ); - if (token == null || !mounted) return; - setState(() => _foreign = token); - if (tapped.isQuantus) _setDirection(swapOut: !_swapOut); + if (token == null || !mounted || token == tapped) return; + if (!token.isQuantus) setState(() => _foreign = token); + if (token.isQuantus != tapped.isQuantus) _setDirection(swapOut: !_swapOut); } /// The sheet quotes with the service of the moment, so a config change diff --git a/quantus_sdk/lib/src/models/swap_token.dart b/quantus_sdk/lib/src/models/swap_token.dart index 22849cb9..3c7394ea 100644 --- a/quantus_sdk/lib/src/models/swap_token.dart +++ b/quantus_sdk/lib/src/models/swap_token.dart @@ -33,8 +33,6 @@ class SwapToken { final String network; final int decimals; final double usdPrice; - final String? iconUrl; - final String? networkIconUrl; /// QTC's side of a swap: the token that is QTC, or stands in for it. final bool isQuantus; @@ -45,25 +43,12 @@ class SwapToken { required this.network, required this.decimals, required this.usdPrice, - this.iconUrl, - this.networkIconUrl, this.isQuantus = false, }); /// Human name of [network], e.g. "Ethereum" for ETH; the code itself when unknown. String get networkName => _networkNames[network] ?? network; - SwapToken copyWith({String? iconUrl, String? networkIconUrl}) => SwapToken( - assetId: assetId, - symbol: symbol, - network: network, - decimals: decimals, - usdPrice: usdPrice, - iconUrl: iconUrl ?? this.iconUrl, - networkIconUrl: networkIconUrl ?? this.networkIconUrl, - isQuantus: isQuantus, - ); - @override bool operator ==(Object other) => other is SwapToken && assetId == other.assetId; diff --git a/quantus_sdk/lib/src/services/swap_service.dart b/quantus_sdk/lib/src/services/swap_service.dart index 71352e96..7c342da2 100644 --- a/quantus_sdk/lib/src/services/swap_service.dart +++ b/quantus_sdk/lib/src/services/swap_service.dart @@ -65,18 +65,14 @@ class SwapService { static const statusPollInterval = Duration(seconds: 5); static const _savedAddressesKey = 'swap_saved_addresses'; static const _maxSavedAddresses = 50; - static const _coinGeckoTopUrl = - 'https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=150&page=1&sparkline=false'; static const _tokensCacheTtl = Duration(minutes: 10); - final http.Client _client; final OneClickService _api; final String _managerPublicKey; final String? _configuredQuantusAssetId; final bool _preflight; (List, SwapToken?)? _cachedListing; DateTime? _cachedListingAt; - List? _cachedFromTokens; /// [quantusAssetId] names the listing that is QTC when its chain code does /// not; it comes from remote config so a surprising listing needs no update. @@ -89,8 +85,7 @@ class SwapService { String managerPublicKey = AppConstants.oneClickManagerPublicKey, String? quantusAssetId, bool preflight = false, - }) : _client = client ?? http.Client(), - _api = OneClickService(client: client, endpoint: endpoint, apiKey: apiKey), + }) : _api = OneClickService(client: client, endpoint: endpoint, apiKey: apiKey), _managerPublicKey = managerPublicKey, _configuredQuantusAssetId = quantusAssetId, _preflight = preflight; @@ -99,15 +94,16 @@ class SwapService { static Duration depositWindowFor(String network) => _slowNetworks.contains(network) ? _slowDepositWindow : depositWindow; + /// The tokens to swap between: QTC first, USDC second, the rest as 1Click + /// orders them, one asset per symbol. Future> getFromTokens({int limit = 10, bool forceRefresh = false}) async { - final (tokens, _) = await _listing(forceRefresh: forceRefresh); - final ranked = _cachedFromTokens ??= await _rankByCoinGecko(tokens); - return ranked.take(limit).toList(); + final (tokens, quantus) = await _listing(forceRefresh: forceRefresh); + final usdc = tokens.where((t) => t.symbol == 'USDC').firstOrNull; + return [?quantus, ?usdc, ...tokens.where((t) => t != usdc)].take(limit).toList(); } /// QTC as 1Click lists it, with its asset id, decimals and price; null until - /// it is listed with a price, and swaps are unavailable until then. Costs - /// the listing alone, never the CoinGecko ranking. + /// it is listed with a price, and swaps are unavailable until then. Future getListedQuantusToken({bool forceRefresh = false}) async => (await _listing(forceRefresh: forceRefresh)).$2; @@ -118,7 +114,6 @@ class SwapService { final listing = await _fetchIntentsTokens(); _cachedListing = listing; _cachedListingAt = now; - _cachedFromTokens = null; return listing; } @@ -280,8 +275,9 @@ class SwapService { return SwapOrder.fromStatusJson(json as Map, quote: order.quote); } - /// The tokens to swap with, one per symbol, and QTC as listed. A token is on - /// Quantus when 1Click says so or when its asset id is the configured one. + /// The tokens to swap with, one per symbol in 1Click's order, and QTC as + /// listed. A token is on Quantus when 1Click says so or when its asset id is + /// the configured one. Future<(List, SwapToken?)> _fetchIntentsTokens() async { final data = await _api.send('GET', '/v0/tokens') as List; final bySymbol = {}; @@ -297,7 +293,6 @@ class SwapService { network: network, decimals: (item['decimals'] as num).toInt(), usdPrice: (item['price'] as num?)?.toDouble() ?? 0, - networkIconUrl: _networkIconUrl(network), isQuantus: ours, ); if (ours) { @@ -347,41 +342,6 @@ class SwapService { return listed; } - /// Orders [tokens] by CoinGecko market cap and picks up their icons. A - /// CoinGecko failure only costs the ordering, so it is logged, not thrown. - Future> _rankByCoinGecko(List tokens) async { - final rankBySymbol = {}; - final iconBySymbol = {}; - try { - final response = await _client.get(Uri.parse(_coinGeckoTopUrl)); - if (response.statusCode < 200 || response.statusCode >= 300) { - throw SwapApiException(response.statusCode, response.body); - } - final payload = jsonDecode(response.body) as List; - for (var i = 0; i < payload.length; i++) { - final item = payload[i] as Map; - final symbol = (item['symbol'] as String).toUpperCase(); - if (rankBySymbol.containsKey(symbol)) continue; - rankBySymbol[symbol] = i; - final icon = item['image'] as String?; - if (icon != null && icon.isNotEmpty) iconBySymbol[symbol] = icon; - } - } catch (e) { - quantusPrint('CoinGecko ranking failed, sorting swap tokens by price: $e'); - } - final ranked = [ - for (final token in tokens) - token.copyWith(iconUrl: iconBySymbol[token.symbol] ?? _fallbackTokenIconUrl(token.symbol)), - ]; - ranked.sort((a, b) { - final ar = rankBySymbol[a.symbol] ?? 99999; - final br = rankBySymbol[b.symbol] ?? 99999; - if (ar != br) return ar.compareTo(br); - return b.usdPrice.compareTo(a.usdPrice); - }); - return ranked; - } - int _networkPriority(String network) { switch (network) { case 'ETH': @@ -401,52 +361,6 @@ class SwapService { } } - String? _fallbackTokenIconUrl(String symbol) { - switch (symbol) { - case 'USDC': - return 'https://assets.coingecko.com/coins/images/6319/large/usdc.png'; - case 'USDT': - return 'https://assets.coingecko.com/coins/images/325/large/Tether.png'; - case 'ETH': - case 'WETH': - return 'https://assets.coingecko.com/coins/images/279/large/ethereum.png'; - case 'BTC': - case 'WBTC': - case 'XBTC': - return 'https://assets.coingecko.com/coins/images/1/large/bitcoin.png'; - case 'SOL': - return 'https://assets.coingecko.com/coins/images/4128/large/solana.png'; - case 'NEAR': - case 'WNEAR': - return 'https://assets.coingecko.com/coins/images/10365/large/near.jpg'; - default: - return null; - } - } - - String? _networkIconUrl(String network) { - switch (network) { - case 'ETH': - case 'BASE': - case 'ARB': - case 'OP': - case 'GNOSIS': - case 'AVAX': - case 'POL': - case 'MONAD': - case 'BSC': - return 'https://assets.coingecko.com/coins/images/279/large/ethereum.png'; - case 'BTC': - return 'https://assets.coingecko.com/coins/images/1/large/bitcoin.png'; - case 'SOL': - return 'https://assets.coingecko.com/coins/images/4128/large/solana.png'; - case 'NEAR': - return 'https://assets.coingecko.com/coins/images/10365/large/near.jpg'; - default: - return null; - } - } - /// Remembers [address] on [network] for future swaps, most recent first. Future saveAddress(String network, String address) async { final prefs = await SharedPreferences.getInstance(); diff --git a/quantus_sdk/test/services/swap_service_test.dart b/quantus_sdk/test/services/swap_service_test.dart index d304bed7..2bfc3f58 100644 --- a/quantus_sdk/test/services/swap_service_test.dart +++ b/quantus_sdk/test/services/swap_service_test.dart @@ -461,7 +461,7 @@ void main() { isQuantus: true, ); expect(quantus.networkName, 'Quantus'); - expect(quantus.copyWith(iconUrl: 'x').isQuantus, isTrue); + expect(quantus.isQuantus, isTrue); }); }); @@ -596,10 +596,7 @@ void main() { ]; SwapService listing(List> list, {String? quantusAssetId, bool preflight = false}) => _service( - (r) async { - if (r.url.host == 'api.coingecko.com') return http.Response('down', 503); - return http.Response(jsonEncode(list), 200); - }, + (r) async => http.Response(jsonEncode(list), 200), quantusAssetId: quantusAssetId, preflight: preflight, ); @@ -615,24 +612,17 @@ void main() { expect(quantus.networkName, 'Quantus'); expect(quantus.decimals, AppConstants.decimals); expect(quantus.usdPrice, 1.5); - expect((await service.getFromTokens()).map((t) => t.symbol), isNot(contains('QTC'))); + final listed = await service.getFromTokens(); + expect(listed.first, quantus); + expect(listed.where((t) => t.symbol == 'QTC'), [quantus]); }, ); - test('the listed QTC costs the listing alone, never CoinGecko', () async { - final service = _service((r) async { - if (r.url.host == 'api.coingecko.com') fail('CoinGecko was asked for the price of QTC'); - return http.Response(jsonEncode(tokens), 200); - }); - expect((await service.getListedQuantusToken())!.usdPrice, 1.5); - }); - test('revalidates the listing by its ETag and keeps the body on a 304', () async { final sent = []; var price = 1.5; var version = 'W/"v1"'; final service = _service((r) async { - if (r.url.host == 'api.coingecko.com') return http.Response('down', 503); sent.add(r.headers['If-None-Match']); if (r.headers['If-None-Match'] == version) return http.Response('', 304); final body = [ @@ -664,7 +654,9 @@ void main() { expect(quantus.isQuantus, isTrue); expect(quantus.network, SwapToken.quantusNetwork); expect(quantus.usdPrice, 2); - expect((await service.getFromTokens()).map((t) => t.assetId), isNot(contains(listedId))); + final listed = await service.getFromTokens(); + expect(listed.first.assetId, listedId); + expect(listed.skip(1).map((t) => t.assetId), isNot(contains(listedId))); }); test('a configured asset id picks QTC out of several tokens on Quantus', () async { @@ -722,7 +714,9 @@ void main() { expect(standIn.symbol, 'WNEAR'); expect(standIn.network, 'NEAR'); expect(standIn.decimals, 24); - expect((await service.getFromTokens()).map((t) => t.symbol), ['BTC', 'USDC']); + final listed = await service.getFromTokens(); + expect(listed.first, standIn); + expect(listed.map((t) => t.symbol), ['WNEAR', 'USDC', 'BTC']); }); test('a QTC listing without a price leaves swaps unavailable', () async { @@ -732,44 +726,32 @@ void main() { expect(await service.getListedQuantusToken(), isNull); }); - test('keeps one asset per symbol, preferring the main network, ranked by CoinGecko', () async { + test('lists QTC first, USDC second and the rest as 1Click orders them, one asset per symbol', () async { final service = _service((r) async { - if (r.url.host == 'api.coingecko.com') { - return http.Response( - jsonEncode([ - {'symbol': 'btc', 'image': 'https://img/btc.png'}, - {'symbol': 'usdc', 'image': 'https://img/usdc.png'}, - ]), - 200, - ); - } expect(r.url.toString(), 'https://oneclick.test/v0/tokens'); return http.Response(jsonEncode(tokens), 200); }); final result = await service.getFromTokens(); - expect(result.map((t) => t.symbol), ['BTC', 'USDC', 'WNEAR']); + expect(result.map((t) => t.symbol), ['QTC', 'USDC', 'WNEAR', 'BTC']); + expect(result.first.isQuantus, isTrue); final usdc = result[1]; expect(usdc.assetId, _usdcEth.assetId); expect(usdc.network, 'ETH'); expect(usdc.decimals, 6); expect(usdc.usdPrice, 0.99966); - expect(usdc.iconUrl, 'https://img/usdc.png'); - expect(usdc.networkIconUrl, isNotNull); - expect(result[2].iconUrl, contains('near')); }); - test('falls back to price order when CoinGecko is down, and caches', () async { + test("keeps 1Click's order when USDC is not listed, and caches the listing", () async { var tokenCalls = 0; final service = _service((r) async { - if (r.url.host == 'api.coingecko.com') return http.Response('down', 503); tokenCalls++; - return http.Response(jsonEncode(tokens), 200); + return http.Response(jsonEncode(tokens.where((t) => t['symbol'] != 'USDC').toList()), 200); }); - expect((await service.getFromTokens()).map((t) => t.symbol), ['BTC', 'WNEAR', 'USDC']); - expect((await service.getFromTokens(limit: 2)).map((t) => t.symbol), ['BTC', 'WNEAR']); + expect((await service.getFromTokens()).map((t) => t.symbol), ['QTC', 'WNEAR', 'BTC']); + expect((await service.getFromTokens(limit: 2)).map((t) => t.symbol), ['QTC', 'WNEAR']); expect(tokenCalls, 1); await service.getFromTokens(forceRefresh: true); expect(tokenCalls, 2);