diff --git a/mobile-app/lib/providers/currency_display_provider.dart b/mobile-app/lib/providers/currency_display_provider.dart index 45b49962..428b34c2 100644 --- a/mobile-app/lib/providers/currency_display_provider.dart +++ b/mobile-app/lib/providers/currency_display_provider.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:decimal/decimal.dart'; @@ -9,6 +10,7 @@ import 'package:resonance_network_wallet/providers/account_providers.dart'; import 'package:resonance_network_wallet/providers/wallet_providers.dart'; import 'package:resonance_network_wallet/services/exchange_rate_service.dart'; import 'package:resonance_network_wallet/shared/utils/print.dart'; +import 'package:resonance_network_wallet/v2/screens/swap/swap_providers.dart'; // --------------------------------------------------------------------------- // Exchange rate caching helpers @@ -109,6 +111,38 @@ final exchangeRatesProvider = FutureProvider>((ref) async { } }); +// --------------------------------------------------------------------------- +// Token price provider +// --------------------------------------------------------------------------- + +/// How often 1Click is asked for QTC's price; an unchanged listing costs a 304. +const tokenPriceRefreshInterval = Duration(minutes: 5); + +/// QTC's USD price as 1Click lists it, refreshed every +/// [tokenPriceRefreshInterval]. A failed refresh is logged and the last price +/// stands; there is none until the first lands. +final tokenUsdPriceProvider = StreamProvider((ref) { + final swap = ref.watch(swapServiceProvider); + final prices = StreamController(); + + Future refresh() async { + try { + final listed = await swap.getListedQuantusToken(forceRefresh: true); + if (listed != null && !prices.isClosed) prices.add(Decimal.parse(listed.usdPrice.toString())); + } catch (e) { + quantusPrint('QTC price refresh failed: $e'); + } + } + + final timer = Timer.periodic(tokenPriceRefreshInterval, (_) => refresh()); + ref.onDispose(() { + timer.cancel(); + prices.close(); + }); + unawaited(refresh()); + return prices.stream; +}); + // --------------------------------------------------------------------------- // Exchange rate service provider // --------------------------------------------------------------------------- @@ -118,14 +152,19 @@ final exchangeRatesProvider = FutureProvider>((ref) async { /// • While [exchangeRatesProvider] is loading, uses the last persisted rates /// (any age) so the UI always shows something meaningful. /// • Once live rates arrive, rebuilds with the fresh data. +/// • Carries QTC's USD price once [tokenUsdPriceProvider] has one. final exchangeRateServiceProvider = Provider((ref) { final ratesAsync = ref.watch(exchangeRatesProvider); final settings = ref.read(settingsServiceProvider); + final tokenToUsdRate = ref.watch(tokenUsdPriceProvider).value; + + ExchangeRateService service(Map rates) => + ExchangeRateService(rates: rates, tokenToUsdRate: tokenToUsdRate); return ratesAsync.when( - data: (rates) => ExchangeRateService(rates: rates), - loading: () => ExchangeRateService(rates: _readRatesCacheAnyAge(settings)), - error: (_, _) => ExchangeRateService(rates: _readRatesCacheAnyAge(settings)), + data: service, + loading: () => service(_readRatesCacheAnyAge(settings)), + error: (_, _) => service(_readRatesCacheAnyAge(settings)), ); }); @@ -178,10 +217,12 @@ class SelectedFiatCurrencyNotifier extends StateNotifier { /// No conversion math belongs in widgets. class CurrencyDisplayState { final String primaryAmount; - final String secondaryAmount; + + /// The amount in [selectedFiat]; null when it is not to be shown or no token price is known. + final String? secondaryAmount; final FiatCurrency selectedFiat; - const CurrencyDisplayState({required this.primaryAmount, required this.secondaryAmount, required this.selectedFiat}); + const CurrencyDisplayState({required this.primaryAmount, this.secondaryAmount, required this.selectedFiat}); CurrencyDisplayState copyWith({String? primaryAmount, String? secondaryAmount, FiatCurrency? selectedFiat}) => CurrencyDisplayState( @@ -214,18 +255,17 @@ final balanceDisplayProvider = Provider>((ref) return balanceAsync.when( loading: () => const AsyncValue.loading(), error: (err, stack) => AsyncValue.error(err, stack), - data: (balance) { - CurrencyDisplayState data = _toFiatDisplayState( + data: (balance) => AsyncValue.data( + _toFiatDisplayState( balance, selectedFiat, - xRate, fmt, tokenDecimals: 3, withTokenSymbol: false, localeConfig: localeConfig, - ); - return AsyncValue.data(data); - }, + fiatRate: xRate, + ), + ), ); }); @@ -244,7 +284,6 @@ typedef TxAmountFormatter = final txAmountDisplayProvider = Provider((ref) { final selectedFiat = ref.watch(selectedFiatCurrencyProvider); - final xRate = ref.watch(exchangeRateServiceProvider); final fmt = ref.watch(numberFormattingServiceProvider); final localeConfig = ref.watch(localeNumberConfigProvider); @@ -260,7 +299,6 @@ final txAmountDisplayProvider = Provider((ref) { CurrencyDisplayState data = _toFiatDisplayState( amount, selectedFiat, - xRate, fmt, tokenDecimals: tokenDecimals, withTokenSymbol: withTokenSymbol, @@ -294,21 +332,24 @@ String _toFiatNumeric( return localeConfig.localize(canonical); } +/// [fiatRate] adds the fiat amount; it stays out while no token price is known. CurrencyDisplayState _toFiatDisplayState( BigInt amount, FiatCurrency selectedFiat, - ExchangeRateService xRate, NumberFormattingService fmt, { required int tokenDecimals, required bool withTokenSymbol, required LocaleNumberConfig localeConfig, + ExchangeRateService? fiatRate, }) { final tokenFormatted = fmt.formatBalance(amount, smartDecimals: tokenDecimals, addSymbol: withTokenSymbol); - final fiatFormatted = selectedFiat.format(_toFiatNumeric(amount, selectedFiat, xRate, localeConfig: localeConfig)); + final withFiat = fiatRate != null && fiatRate.hasTokenPrice; return CurrencyDisplayState( primaryAmount: tokenFormatted, - secondaryAmount: fiatFormatted, + secondaryAmount: withFiat + ? selectedFiat.format(_toFiatNumeric(amount, selectedFiat, fiatRate, localeConfig: localeConfig)) + : null, selectedFiat: selectedFiat, ); } diff --git a/mobile-app/lib/services/exchange_rate_service.dart b/mobile-app/lib/services/exchange_rate_service.dart index 54d8b315..fe5baebd 100644 --- a/mobile-app/lib/services/exchange_rate_service.dart +++ b/mobile-app/lib/services/exchange_rate_service.dart @@ -6,8 +6,8 @@ import 'package:resonance_network_wallet/models/fiat_currency.dart'; /// Constructed with a live [rates] map (ISO-4217 code → value in that currency /// per 1 USD). Falls back to [fallbackRates] for any code not present. /// -/// [tokenToUsdRate] defaults to `1` (1 token = 1 USD). Wire a dedicated tokens -/// price feed into this field when one becomes available. +/// [tokenToUsdRate] is the token's USD price, null while none is known; +/// converting without one throws. class ExchangeRateService { /// Static rates used before any live or cached data is available (e.g. on /// fresh install with no network). Values are approximate and intentionally @@ -23,11 +23,13 @@ class ExchangeRateService { }; final Map _rates; - final Decimal tokenToUsdRate; + final Decimal? tokenToUsdRate; - ExchangeRateService({required Map rates, Decimal? tokenToUsdRate}) - : _rates = rates, - tokenToUsdRate = tokenToUsdRate ?? Decimal.one; + ExchangeRateService({required Map rates, this.tokenToUsdRate}) : _rates = rates; + + bool get hasTokenPrice => tokenToUsdRate != null; + + Decimal get _tokenPrice => tokenToUsdRate ?? (throw StateError('Token price unknown')); /// Returns the exchange rate for [fiat] (units per 1 USD). Decimal getRate(FiatCurrency fiat) { @@ -39,7 +41,7 @@ class ExchangeRateService { /// Converts [tokenAmount] to [fiat] using the current rates. Decimal convert(Decimal tokenAmount, FiatCurrency fiat) { - final result = (tokenAmount * tokenToUsdRate * getRate(fiat)); + final result = (tokenAmount * _tokenPrice * getRate(fiat)); // Round to fiat precision to ensure stable round-trips return Decimal.parse(result.toStringAsFixed(fiat.decimals)); } @@ -59,7 +61,7 @@ class ExchangeRateService { /// Uses the inverse of [convert]: fiat / (tokenToUsdRate × rate). /// Returns [BigInt.zero] when the effective rate is zero. BigInt fiatToToken(Decimal fiatAmount, FiatCurrency fiat, int tokenDecimals) { - final effectiveRate = tokenToUsdRate * getRate(fiat); + final effectiveRate = _tokenPrice * getRate(fiat); if (effectiveRate == Decimal.zero) return BigInt.zero; final scaleFactor = Decimal.fromBigInt(BigInt.from(10).pow(tokenDecimals)); final tokenDecimal = (fiatAmount / effectiveRate).toDecimal(scaleOnInfinitePrecision: tokenDecimals); diff --git a/mobile-app/lib/v2/components/amount_display_with_conversion.dart b/mobile-app/lib/v2/components/amount_display_with_conversion.dart index 33f5d59f..afab85dd 100644 --- a/mobile-app/lib/v2/components/amount_display_with_conversion.dart +++ b/mobile-app/lib/v2/components/amount_display_with_conversion.dart @@ -13,7 +13,7 @@ class AmountDisplayWithConversion extends StatelessWidget { final Color? amountColor; final bool useTokenLogo; - /// Masks the amount with [hiddenAmountText]. Owned by the screen that + /// Masks the amounts with [hiddenAmountText]. Owned by the screen that /// offers the hide toggle — never read from a global setting here. final bool isHidden; @@ -36,41 +36,55 @@ class AmountDisplayWithConversion extends StatelessWidget { const tokenLogoPrimarySize = 32.0; final primaryAmount = isHidden ? hiddenAmountText : amountDisplay.primaryAmount; + final secondaryAmount = amountDisplay.secondaryAmount; final MainAxisAlignment mainAxisAlignment = switch (alignment) { CrossAxisAlignment.center => MainAxisAlignment.center, _ => MainAxisAlignment.start, }; - return Row( - mainAxisAlignment: mainAxisAlignment, + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: alignment, children: [ - if (useTokenLogo) ...[ - SvgPicture.asset( - 'assets/v2/uppercase_q.svg', - width: tokenLogoPrimarySize, - height: tokenLogoPrimarySize, - colorFilter: ColorFilter.mode(colors.textContent, BlendMode.srcIn), - ), - const SizedBox(width: 4), - ], - Text.rich( - TextSpan( - children: [ - TextSpan( - text: primaryAmount, - style: text.displayBalance.copyWith(color: primaryAmountColor), + Row( + mainAxisAlignment: mainAxisAlignment, + children: [ + if (useTokenLogo) ...[ + SvgPicture.asset( + 'assets/v2/uppercase_q.svg', + width: tokenLogoPrimarySize, + height: tokenLogoPrimarySize, + colorFilter: ColorFilter.mode(colors.textContent, BlendMode.srcIn), ), - if (!useTokenLogo) ...[ - const TextSpan(text: ' '), - TextSpan( - text: AppConstants.tokenSymbol, - style: text.amountHero.copyWith(color: primaryAmountColor), - ), - ], + const SizedBox(width: 4), ], - ), + Text.rich( + TextSpan( + children: [ + TextSpan( + text: primaryAmount, + style: text.displayBalance.copyWith(color: primaryAmountColor), + ), + if (!useTokenLogo) ...[ + const TextSpan(text: ' '), + TextSpan( + text: AppConstants.tokenSymbol, + style: text.amountHero.copyWith(color: primaryAmountColor), + ), + ], + ], + ), + ), + ], ), + if (secondaryAmount != null) ...[ + const SizedBox(height: 8), + Text( + '≈ ${isHidden ? hiddenAmountText : secondaryAmount}', + style: text.body.copyWith(color: colors.textMuted), + ), + ], ], ); } 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/mobile-app/test/unit/amount_input_logic_test.dart b/mobile-app/test/unit/amount_input_logic_test.dart index d2d618d0..931312a9 100644 --- a/mobile-app/test/unit/amount_input_logic_test.dart +++ b/mobile-app/test/unit/amount_input_logic_test.dart @@ -12,7 +12,7 @@ void main() { late NumberFormattingService formattingService; setUp(() { - exchangeRateService = ExchangeRateService(rates: {'USD': Decimal.one}); + exchangeRateService = ExchangeRateService(rates: {'USD': Decimal.one}, tokenToUsdRate: Decimal.one); localeConfig = LocaleNumberConfig.dotDecimal; formattingService = NumberFormattingService(localeConfig: localeConfig); }); diff --git a/mobile-app/test/unit/balance_fiat_line_test.dart b/mobile-app/test/unit/balance_fiat_line_test.dart new file mode 100644 index 00000000..711c0519 --- /dev/null +++ b/mobile-app/test/unit/balance_fiat_line_test.dart @@ -0,0 +1,71 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/models/fiat_currency.dart'; +import 'package:resonance_network_wallet/providers/currency_display_provider.dart'; +import 'package:resonance_network_wallet/providers/wallet_providers.dart'; +import 'package:resonance_network_wallet/v2/components/amount_display_with_conversion.dart'; + +import '../extensions.dart'; +import '../fakes.dart'; + +void main() { + final oneAndAHalf = BigInt.from(15) * BigInt.from(10).pow(11); + + ProviderContainer container({Decimal? price}) { + final c = ProviderContainer( + overrides: [ + settingsServiceProvider.overrideWithValue(FakeSettingsService(activeAccount: RegularAccount(makeAccount(1)))), + balanceProvider.overrideWithValue(AsyncValue.data(oneAndAHalf)), + exchangeRatesProvider.overrideWith((ref) async => {'USD': Decimal.one}), + tokenUsdPriceProvider.overrideWith( + (ref) => price == null ? const Stream.empty() : Stream.value(price), + ), + ], + ); + addTearDown(c.dispose); + return c; + } + + /// Listens, which is what runs the stream, and waits for the rates and any price to land. + Future ready({Decimal? price}) async { + final c = container(price: price); + c.listen(balanceDisplayProvider, (_, _) {}); + await c.read(exchangeRatesProvider.future); + if (price != null) await c.read(tokenUsdPriceProvider.future); + return c; + } + + test('the balance carries no fiat amount until QTC has a price', () async { + final c = await ready(); + expect(c.read(balanceDisplayProvider).requireValue.secondaryAmount, isNull); + }); + + test('the balance is priced in the selected fiat once QTC has a price', () async { + final c = await ready(price: Decimal.parse('2')); + expect(c.read(balanceDisplayProvider).requireValue.secondaryAmount, r'$3.00'); + }); + + test('transaction amounts carry no fiat amount', () async { + final c = await ready(price: Decimal.parse('2')); + expect(c.read(txAmountDisplayProvider)(oneAndAHalf, isSend: true).secondaryAmount, isNull); + }); + + testWidgets('the fiat line shows under the amount and hides with it', (tester) async { + const priced = CurrencyDisplayState( + primaryAmount: '1.5', + secondaryAmount: r'$3.00', + selectedFiat: FiatCurrency.usd, + ); + await tester.pumpApp(const AmountDisplayWithConversion(amountDisplay: priced)); + expect(find.text(r'≈ $3.00'), findsOneWidget); + + await tester.pumpApp(const AmountDisplayWithConversion(amountDisplay: priced, isHidden: true)); + expect(find.text('≈ $hiddenAmountText'), findsOneWidget); + + const unpriced = CurrencyDisplayState(primaryAmount: '1.5', selectedFiat: FiatCurrency.usd); + await tester.pumpApp(const AmountDisplayWithConversion(amountDisplay: unpriced)); + expect(find.textContaining('≈'), findsNothing); + }); +} diff --git a/mobile-app/test/unit/exchange_rate_service_test.dart b/mobile-app/test/unit/exchange_rate_service_test.dart index 7bd51b84..7ba4411e 100644 --- a/mobile-app/test/unit/exchange_rate_service_test.dart +++ b/mobile-app/test/unit/exchange_rate_service_test.dart @@ -10,7 +10,7 @@ void main() { late ExchangeRateService service; setUp(() { - service = ExchangeRateService(rates: rates); + service = ExchangeRateService(rates: rates, tokenToUsdRate: Decimal.one); }); group('ExchangeRateService.getRate', () { @@ -38,6 +38,13 @@ void main() { expect(service.convert(Decimal.parse('0.5'), FiatCurrency.myr), Decimal.parse('1.99')); }); + test('throws while the token price is unknown', () { + final unpriced = ExchangeRateService(rates: rates); + expect(unpriced.hasTokenPrice, isFalse); + expect(() => unpriced.convert(Decimal.one, FiatCurrency.usd), throwsStateError); + expect(() => unpriced.fiatToToken(Decimal.one, FiatCurrency.usd, 12), throwsStateError); + }); + test('applies tokenToUsdRate when set', () { final serviceWith2xRate = ExchangeRateService(rates: rates, tokenToUsdRate: Decimal.parse('2')); // 1 token × 2 USD/token × 3.97 MYR/USD = 7.94 MYR @@ -103,7 +110,7 @@ void main() { // we ensure that the round-tripped tokens value is the canonical tokens // representation of that specific fiat amount. const tokenDecimals = 12; - final lossyService = ExchangeRateService(rates: {'MYR': Decimal.parse('3.971')}); + final lossyService = ExchangeRateService(rates: {'MYR': Decimal.parse('3.971')}, tokenToUsdRate: Decimal.one); final original = BigInt.from(1_500_000_000_000); // 1.5 tokens // 1.5 * 3.971 = 5.9565 -> rounded to 5.96 MYR 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/one_click_service.dart b/quantus_sdk/lib/src/services/one_click_service.dart index 7f4f020d..65b2be5f 100644 --- a/quantus_sdk/lib/src/services/one_click_service.dart +++ b/quantus_sdk/lib/src/services/one_click_service.dart @@ -28,6 +28,10 @@ class OneClickService { /// issues an address that already exists, so neither answer goes stale. final _depositAddresses = {}; + /// The last answer to each GET with its ETag, so asking again costs a 304 + /// and no body while the resource is unchanged. + final _etagged = {}; + OneClickService({http.Client? client, String endpoint = AppConstants.oneClickEndpoint, String? apiKey}) : _client = client ?? http.Client(), _base = Uri.parse(endpoint), @@ -52,10 +56,12 @@ class OneClickService { Future send(String method, String path, {Map? body, Map? query}) async { final uri = _base.replace(path: path, queryParameters: query); - final headers = {'Content-Type': 'application/json', 'X-API-Key': ?_apiKey}; + final cached = method == 'GET' ? _etagged[uri] : null; + final headers = {'Content-Type': 'application/json', 'X-API-Key': ?_apiKey, 'If-None-Match': ?cached?.$1}; final response = method == 'GET' ? await _client.get(uri, headers: headers) : await _client.post(uri, headers: headers, body: jsonEncode(body)); + if (response.statusCode == 304 && cached != null) return cached.$2; final ok = response.statusCode >= 200 && response.statusCode < 300; Object? json; try { @@ -71,6 +77,8 @@ class OneClickService { _ => response.body, }); } + final etag = response.headers['etag']; + if (method == 'GET' && etag != null) _etagged[uri] = (etag, json); return json; } } diff --git a/quantus_sdk/lib/src/services/swap_service.dart b/quantus_sdk/lib/src/services/swap_service.dart index b1f1020d..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? _cachedFromTokens; - DateTime? _cachedFromTokensAt; - SwapToken? _listedQuantus; + (List, SwapToken?)? _cachedListing; + DateTime? _cachedListingAt; /// [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,26 +94,27 @@ class SwapService { static Duration depositWindowFor(String network) => _slowNetworks.contains(network) ? _slowDepositWindow : depositWindow; - Future> getFromTokens({int limit = 10, bool forceRefresh = false}) async => - (await _tokens(forceRefresh: forceRefresh)).take(limit).toList(); + /// 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, 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. - Future getListedQuantusToken({bool forceRefresh = false}) async { - await _tokens(forceRefresh: forceRefresh); - return _listedQuantus; - } + Future getListedQuantusToken({bool forceRefresh = false}) async => + (await _listing(forceRefresh: forceRefresh)).$2; - Future> _tokens({required bool forceRefresh}) async { + Future<(List, SwapToken?)> _listing({required bool forceRefresh}) async { final now = DateTime.now(); - final cached = _cachedFromTokens; - if (!forceRefresh && cached != null && now.difference(_cachedFromTokensAt!) < _tokensCacheTtl) return cached; - final (tokens, quantus) = await _fetchIntentsTokens(); - final ranked = await _rankByCoinGecko(tokens); - _cachedFromTokens = ranked; - _listedQuantus = quantus; - _cachedFromTokensAt = now; - return ranked; + final cached = _cachedListing; + if (!forceRefresh && cached != null && now.difference(_cachedListingAt!) < _tokensCacheTtl) return cached; + final listing = await _fetchIntentsTokens(); + _cachedListing = listing; + _cachedListingAt = now; + return listing; } /// Asks solvers for a price on [amount] base units of [from]. A dry quote is @@ -279,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 = {}; @@ -296,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) { @@ -346,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': @@ -400,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 938e4f64..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,10 +612,32 @@ 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('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 { + sent.add(r.headers['If-None-Match']); + if (r.headers['If-None-Match'] == version) return http.Response('', 304); + final body = [ + for (final t in tokens) t['symbol'] == 'QTC' ? {...t, 'price': price} : t, + ]; + return http.Response(jsonEncode(body), 200, headers: {'etag': version}); + }); + expect((await service.getListedQuantusToken())!.usdPrice, 1.5); + expect((await service.getListedQuantusToken(forceRefresh: true))!.usdPrice, 1.5); + price = 2; + version = 'W/"v2"'; + expect((await service.getListedQuantusToken(forceRefresh: true))!.usdPrice, 2); + expect(sent, [null, 'W/"v1"', 'W/"v1"']); + }); + test('has no listed QTC until 1Click adds it', () async { final service = listing(tokens.where((t) => t['symbol'] != 'QTC').toList()); expect(await service.getListedQuantusToken(), isNull); @@ -635,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 { @@ -693,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 { @@ -703,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);