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
24 changes: 4 additions & 20 deletions mobile-app/lib/v2/components/token_icon.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -34,36 +32,22 @@ 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),
),
),
],
),
);
}

Widget _fallback(SwapToken token, AppColorsV3 colors, AppTextThemeV3 text) {
Widget _monogram(SwapToken token, AppColorsV3 colors, AppTextThemeV3 text) {
return Container(
decoration: BoxDecoration(
color: colors.bgSurface2,
Expand All @@ -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,
Expand Down
14 changes: 7 additions & 7 deletions mobile-app/lib/v2/screens/swap/swap_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ class _SwapScreenState extends ConsumerState<SwapScreen> {
final quantus = await service.getListedQuantusToken();
if (!mounted || load != _loads) return;
setState(() {
_foreign ??= tokens.first;
_foreign ??= tokens.firstWhere((t) => !t.isQuantus);
_quantus = quantus;
});
} catch (e) {
Expand All @@ -95,18 +95,18 @@ class _SwapScreenState extends ConsumerState<SwapScreen> {
_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<void> _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
Expand Down
15 changes: 0 additions & 15 deletions quantus_sdk/lib/src/models/swap_token.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down
106 changes: 10 additions & 96 deletions quantus_sdk/lib/src/services/swap_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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>, SwapToken?)? _cachedListing;
DateTime? _cachedListingAt;
List<SwapToken>? _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.
Expand All @@ -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;
Expand All @@ -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<List<SwapToken>> 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<SwapToken?> getListedQuantusToken({bool forceRefresh = false}) async =>
(await _listing(forceRefresh: forceRefresh)).$2;

Expand All @@ -118,7 +114,6 @@ class SwapService {
final listing = await _fetchIntentsTokens();
_cachedListing = listing;
_cachedListingAt = now;
_cachedFromTokens = null;
return listing;
}

Expand Down Expand Up @@ -280,8 +275,9 @@ class SwapService {
return SwapOrder.fromStatusJson(json as Map<String, dynamic>, 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>, SwapToken?)> _fetchIntentsTokens() async {
final data = await _api.send('GET', '/v0/tokens') as List<dynamic>;
final bySymbol = <String, SwapToken>{};
Expand All @@ -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) {
Expand Down Expand Up @@ -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<List<SwapToken>> _rankByCoinGecko(List<SwapToken> tokens) async {
final rankBySymbol = <String, int>{};
final iconBySymbol = <String, String>{};
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<dynamic>;
for (var i = 0; i < payload.length; i++) {
final item = payload[i] as Map<String, dynamic>;
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':
Expand All @@ -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<void> saveAddress(String network, String address) async {
final prefs = await SharedPreferences.getInstance();
Expand Down
Loading
Loading