dev
dart 569 lines 18.9 KB
Raw
1 import 'dart:convert';
2
3 import 'package:cake_wallet/exchange/exchange_provider_description.dart';
4 import 'package:cake_wallet/exchange/limits.dart';
5 import 'package:cake_wallet/exchange/provider/exchange_provider.dart';
6 import 'package:cake_wallet/exchange/trade.dart';
7 import 'package:cake_wallet/exchange/trade_not_created_exception.dart';
8 import 'package:cake_wallet/exchange/trade_request.dart';
9 import 'package:cake_wallet/exchange/trade_state.dart';
10 import 'package:cake_wallet/utils/package_info.dart';
11 import 'package:cw_core/crypto_currency.dart';
12 import 'package:cw_core/utils/print_verbose.dart';
13 import 'package:cw_core/utils/proxy_wrapper.dart';
14 import 'package:cake_wallet/utils/exchange_provider_logger.dart';
15
16 class XOSwapExchangeProvider extends ExchangeProvider {
17 XOSwapExchangeProvider() {
18 _addAppVersionHeader();
19 }
20
21 void _addAppVersionHeader() async {
22 try {
23 final packageInfo = await PackageInfo.fromPlatform();
24 final currentVersion = packageInfo.version;
25 _headers['App-Version'] = currentVersion;
26 } catch (_) {}
27 }
28
29 static const _apiAuthority = 'exchange.exodus.io';
30 static const _apiPath = '/v3';
31 static const _pairsPath = '/pairs';
32 static const _ratePath = '/rates';
33 static const _orders = '/orders';
34 static const _assets = '/assets';
35
36 static final _headers = {'Content-Type': 'application/json', 'App-Name': 'cake-labs'};
37
38 final _networks = <String, String>{
39 'POL': 'matic',
40 'ETH': 'ethereum',
41 'BTC': 'bitcoin',
42 'BSC': 'bsc',
43 'SOL': 'solana',
44 'TRX': 'tronmainnet',
45 'ZEC': 'zcash',
46 'ADA': 'cardano',
47 'DOGE': 'dogecoin',
48 'XMR': 'monero',
49 'BCH': 'bcash',
50 'BSV': 'bitcoinsv',
51 'XRP': 'ripple',
52 'LTC': 'litecoin',
53 'EOS': 'eosio',
54 'XLM': 'stellar',
55 'BASE': 'basemainnet',
56 'ARB': 'arbitrum',
57 };
58
59 static const supportedTags = [
60 'POL',
61 'ETH',
62 'BTC',
63 'BSC',
64 'SOL',
65 'TRX',
66 'ZEC',
67 'ADA',
68 'DOGE',
69 'XMR',
70 'BCH',
71 'BSV',
72 'XRP',
73 'LTC',
74 'EOS',
75 'XLM',
76 'BASE',
77 'ARB',
78 ];
79
80 String _normalizeXOSwapsNetwork(String string) {
81 final lower = string.toLowerCase();
82
83 if (lower.endsWith('matic0a883d9b'))
84 return string.replaceFirst(RegExp(r'matic0a883d9b$', caseSensitive: false), 'POL');
85 if (lower.endsWith('matic86e249c1'))
86 return string.replaceFirst(RegExp(r'matic86e249c1$', caseSensitive: false), 'POL');
87 if (lower.endsWith('bscddedf0f8'))
88 return string.replaceFirst(RegExp(r'bscddedf0f8$', caseSensitive: false), 'BSC');
89 if (lower.endsWith('basemainnetb5a52617'))
90 return string.replaceFirst(RegExp(r'basemainnetb5a52617$', caseSensitive: false), 'BASE');
91
92 return string;
93 }
94
95 @override
96 String get title => 'XOSwap';
97
98 @override
99 bool get isAvailable => true;
100
101 @override
102 bool get isEnabled => true;
103
104 @override
105 bool get supportsFixedRate => true;
106
107 @override
108 ExchangeProviderDescription get description => ExchangeProviderDescription.xoSwap;
109
110 @override
111 Future<bool> checkIsAvailable() async => true;
112
113 Future<String?> _getAssets(CryptoCurrency currency) async {
114 if (currency.tag == null) return currency.title;
115 try {
116 final normalizedNetwork = _networks[currency.tag];
117 if (normalizedNetwork == null) return null;
118
119 final uri = Uri.https(_apiAuthority, _apiPath + _assets,
120 {'networks': normalizedNetwork, 'query': currency.title});
121
122 final response = await ProxyWrapper().get(clearnetUri: uri, headers: _headers);
123
124 if (response.statusCode != 200) {
125 throw Exception('Failed to fetch assets for ${currency.title} on ${currency.tag}');
126 }
127
128 final decoded = jsonDecode(response.body);
129 if (decoded is! List) throw const FormatException('Unexpected response format');
130 final assets = decoded.map((e) => Map<String, dynamic>.from(e as Map)).toList();
131
132 final asset = assets.firstWhere(
133 (asset) => removeNonAlphanumeric((asset['symbol'] ?? '').toString()) == currency.title,
134 orElse: () => const {},
135 );
136
137 return asset.isEmpty ? null : asset['id'] as String;
138 } catch (e) {
139 printV(e.toString());
140 return null;
141 }
142 }
143
144 String removeNonAlphanumeric(String str) =>
145 str.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), '');
146
147 Future<List<dynamic>> getRatesForPair({
148 required CryptoCurrency from,
149 required CryptoCurrency to,
150 }) async {
151 try {
152 final curFrom = await _getAssets(from);
153 final curTo = await _getAssets(to);
154 if (curFrom == null || curTo == null) return [];
155 final pairId = curFrom + '_' + curTo;
156 final uri = Uri.https(_apiAuthority, '$_apiPath$_pairsPath/$pairId$_ratePath');
157 final response = await ProxyWrapper().get(clearnetUri: uri, headers: _headers);
158
159 if (response.statusCode != 200) return [];
160 return json.decode(response.body) as List<dynamic>;
161 } catch (e) {
162 printV(e.toString());
163 return [];
164 }
165 }
166
167 Future<Limits?> fetchLimits({
168 required CryptoCurrency from,
169 required CryptoCurrency to,
170 required bool isFixedRateMode,
171 }) async {
172 try {
173 final rates = await getRatesForPair(from: from, to: to);
174 if (rates.isEmpty) throw Exception('No rates found for $from to $to');
175
176 double minLimit = double.infinity;
177 double maxLimit = 0;
178
179 for (var rate in rates) {
180 final double currentMin = double.parse(rate['min']['value'].toString());
181 final double currentMax = double.parse(rate['max']['value'].toString());
182 if (currentMin < minLimit) minLimit = currentMin;
183 if (currentMax > maxLimit) maxLimit = currentMax;
184 }
185 return Limits(min: minLimit, max: maxLimit);
186 } catch (e) {
187 printV(e.toString());
188 throw Exception('StealthEx failed to fetch limits');
189 }
190 }
191
192 @override
193 Future<double> fetchRate(
194 {required CryptoCurrency from,
195 required CryptoCurrency to,
196 required double amount,
197 required bool isFixedRateMode,
198 required bool isReceiveAmount}) async {
199 try {
200 final rates = await getRatesForPair(from: from, to: to);
201 if (rates.isEmpty) {
202 ExchangeProviderLogger.logError(
203 provider: description,
204 function: 'fetchRate',
205 error: Exception('No rates found for $from to $to'),
206 stackTrace: StackTrace.current,
207 requestData: {
208 'from': from.title,
209 'to': to.title,
210 'amount': amount,
211 'isFixedRateMode': isFixedRateMode,
212 'isReceiveAmount': isReceiveAmount,
213 },
214 );
215 return 0;
216 }
217
218 double result;
219 if (!isFixedRateMode) {
220 double bestOutput = 0.0;
221 for (var rate in rates) {
222 final double minVal = double.parse(rate['min']['value'].toString());
223 final double maxVal = double.parse(rate['max']['value'].toString());
224 if (amount >= minVal && amount <= maxVal) {
225 final double rateMultiplier = double.parse(rate['amount']['value'].toString());
226 final double minerFee = double.parse(rate['minerFee']['value'].toString());
227 final double outputAmount = (amount * rateMultiplier) - minerFee;
228 if (outputAmount > bestOutput) {
229 bestOutput = outputAmount;
230 }
231 }
232 }
233 result = bestOutput > 0 ? (bestOutput / amount) : 0;
234 } else {
235 double bestInput = double.infinity;
236 for (var rate in rates) {
237 final double rateMultiplier = double.parse(rate['amount']['value'].toString());
238 final double minerFee = double.parse(rate['minerFee']['value'].toString());
239 final double minVal = double.parse(rate['min']['value'].toString());
240 final double maxVal = double.parse(rate['max']['value'].toString());
241 final double requiredSend = (amount + minerFee) / rateMultiplier;
242 if (requiredSend >= minVal && requiredSend <= maxVal) {
243 if (requiredSend < bestInput) {
244 bestInput = requiredSend;
245 }
246 }
247 }
248 result = bestInput < double.infinity ? amount / bestInput : 0;
249 }
250
251 ExchangeProviderLogger.logSuccess(
252 provider: description,
253 function: 'fetchRate',
254 requestData: {
255 'from': from.title,
256 'to': to.title,
257 'amount': amount,
258 'isFixedRateMode': isFixedRateMode,
259 'isReceiveAmount': isReceiveAmount,
260 },
261 responseData: {
262 'result': result,
263 'ratesCount': rates.length,
264 'rates': rates,
265 },
266 );
267
268 return result;
269 } catch (e, s) {
270 ExchangeProviderLogger.logError(
271 provider: description,
272 function: 'fetchRate',
273 error: e,
274 stackTrace: s,
275 requestData: {
276 'from': from.title,
277 'to': to.title,
278 'amount': amount,
279 'isFixedRateMode': isFixedRateMode,
280 'isReceiveAmount': isReceiveAmount,
281 },
282 );
283 printV(e.toString());
284 return 0;
285 }
286 }
287
288 @override
289 Future<Trade> createTrade({
290 required TradeRequest request,
291 required bool isFixedRateMode,
292 required bool isSendAll,
293 }) async {
294 try {
295 final uri = Uri.https(_apiAuthority, '$_apiPath$_orders');
296
297 final curFrom = await _getAssets(request.fromCurrency);
298 final curTo = await _getAssets(request.toCurrency);
299
300 if (curFrom == null || curTo == null) {
301 ExchangeProviderLogger.logError(
302 provider: description,
303 function: 'createTrade',
304 error: TradeNotCreatedException(description),
305 stackTrace: StackTrace.current,
306 requestData: {
307 'from': request.fromCurrency.title,
308 'to': request.toCurrency.title,
309 'fromAmount': request.fromAmount,
310 'toAmount': request.toAmount,
311 'toAddress': request.toAddress,
312 'refundAddress': request.refundAddress,
313 'isFixedRateMode': isFixedRateMode,
314 'isSendAll': isSendAll,
315 'curFrom': curFrom,
316 'curTo': curTo,
317 },
318 );
319 throw TradeNotCreatedException(description);
320 }
321
322 final pairId = curFrom + '_' + curTo;
323
324 final payload = {
325 'fromAmount': request.fromAmount,
326 'fromAddress': request.refundAddress,
327 'toAmount': request.toAmount,
328 'toAddress': request.toAddress,
329 if (request.toAddressExtraId.isNotEmpty) 'toAddressTag': request.toAddressExtraId,
330 'pairId': pairId,
331 };
332
333 final response = await ProxyWrapper().post(
334 clearnetUri: uri,
335 headers: _headers,
336 body: json.encode(payload),
337 );
338
339 if (response.statusCode != 201) {
340 final responseJSON = json.decode(response.body) as Map<String, dynamic>;
341 final error = responseJSON['error'] ?? 'Unknown error';
342 final message = responseJSON['message'] ?? '';
343
344 ExchangeProviderLogger.logError(
345 provider: description,
346 function: 'createTrade',
347 error: Exception('$error\n$message'),
348 stackTrace: StackTrace.current,
349 requestData: {
350 'from': request.fromCurrency.title,
351 'to': request.toCurrency.title,
352 'fromAmount': request.fromAmount,
353 'toAmount': request.toAmount,
354 'toAddress': request.toAddress,
355 'refundAddress': request.refundAddress,
356 'isFixedRateMode': isFixedRateMode,
357 'isSendAll': isSendAll,
358 'payload': payload,
359 'url': uri.toString(),
360 },
361 );
362
363 throw Exception('$error\n$message');
364 }
365 final responseJSON = json.decode(response.body) as Map<String, dynamic>;
366
367 final amount = responseJSON['amount'] as Map<String, dynamic>;
368 final toAmount = responseJSON['toAmount'] as Map<String, dynamic>;
369 final orderId = responseJSON['id'] as String;
370 final from = request.fromCurrency;
371 final to = request.toCurrency;
372 final payoutAddress = responseJSON['toAddress'] as String;
373 final depositAddress = responseJSON['payInAddress'] as String;
374 final refundAddress = responseJSON['fromAddress'] as String;
375 final depositAmountStr = amount['value'].toString();
376 final parsedAmount = double.tryParse(depositAmountStr);
377
378 if (parsedAmount == null || parsedAmount <= 0) {
379 throw Exception('Invalid deposit amount received from API');
380 }
381
382 final receiveAmount = toAmount['value'] as String;
383 final status = responseJSON['status'] as String;
384 final createdAtString = responseJSON['createdAt'] as String;
385 final extraId = responseJSON['payInAddressTag'] as String?;
386
387 final createdAt = DateTime.parse(createdAtString).toLocal();
388
389 ExchangeProviderLogger.logSuccess(
390 provider: description,
391 function: 'createTrade',
392 requestData: {
393 'from': request.fromCurrency.title,
394 'to': request.toCurrency.title,
395 'fromAmount': request.fromAmount,
396 'toAmount': request.toAmount,
397 'toAddress': request.toAddress,
398 'refundAddress': request.refundAddress,
399 'isFixedRateMode': isFixedRateMode,
400 'isSendAll': isSendAll,
401 'payload': payload,
402 'url': uri.toString(),
403 },
404 responseData: {
405 'orderId': orderId,
406 'depositAddress': depositAddress,
407 'payoutAddress': payoutAddress,
408 'refundAddress': refundAddress,
409 'depositAmount': depositAmountStr,
410 'receiveAmount': receiveAmount,
411 'status': status,
412 'createdAt': createdAtString,
413 'extraId': extraId,
414 'statusCode': response.statusCode,
415 'responseJSON': responseJSON,
416 },
417 );
418
419 return Trade(
420 id: orderId,
421 from: from,
422 to: to,
423 provider: description,
424 inputAddress: depositAddress,
425 refundAddress: refundAddress,
426 state: TradeState.deserialize(raw: status),
427 createdAt: createdAt,
428 amount: depositAmountStr,
429 receiveAmount: receiveAmount.toString(),
430 payoutAddress: payoutAddress,
431 extraId: extraId,
432 isSendAll: isSendAll,
433 toAddressExtraId: request.toAddressExtraId,
434 );
435 } catch (e, s) {
436 ExchangeProviderLogger.logError(
437 provider: description,
438 function: 'createTrade',
439 error: e,
440 stackTrace: s,
441 requestData: {
442 'from': request.fromCurrency.title,
443 'to': request.toCurrency.title,
444 'fromAmount': request.fromAmount,
445 'toAmount': request.toAmount,
446 'toAddress': request.toAddress,
447 'refundAddress': request.refundAddress,
448 'isFixedRateMode': isFixedRateMode,
449 'isSendAll': isSendAll,
450 },
451 );
452 printV(e.toString());
453 throw TradeNotCreatedException(description);
454 }
455 }
456
457 @override
458 Future<Trade> findTradeById({required String id}) async {
459 try {
460 final uri = Uri.https(_apiAuthority, '$_apiPath$_orders/$id');
461 final response = await ProxyWrapper().get(clearnetUri: uri, headers: _headers);
462
463 if (response.statusCode != 200) {
464 final responseJSON = json.decode(response.body) as Map<String, dynamic>;
465 if (responseJSON.containsKey('code') && responseJSON['code'] == 'NOT_FOUND') {
466 throw Exception('Trade not found');
467 }
468 final error = responseJSON['error'] ?? 'Unknown error';
469 final message = responseJSON['message'] ?? responseJSON['details'] ?? '';
470 throw Exception('$error\n$message');
471 }
472 final responseJSON = json.decode(response.body) as Map<String, dynamic>;
473
474 final pairId = responseJSON['pairId'] as String;
475 final pairParts = pairId.split('_');
476 final fromAsset = pairParts.isNotEmpty ? pairParts[0] : '';
477 final normalizedFromAsset = _normalizeXOSwapsNetwork(fromAsset);
478 String? fromAssetTag = _extractTagFromAsset(normalizedFromAsset);
479
480 String fromAssetBase = fromAssetTag != null
481 ? normalizedFromAsset.substring(0, normalizedFromAsset.length - fromAssetTag.length)
482 : normalizedFromAsset;
483
484 // Special case for USDT defaulting to ETH tag
485 if (fromAssetBase == 'USDT' && fromAssetTag == null) {
486 fromAssetTag = 'ETH';
487 }
488
489 // Special case for BASE defaulting to BASE tag
490 if (fromAssetBase == 'BASE' && fromAssetTag == null) {
491 fromAssetTag = 'BASE';
492 fromAssetBase = 'ETH';
493 }
494
495 final toAsset = pairParts.length > 1 ? pairParts[1] : '';
496 final normalizedToAsset = _normalizeXOSwapsNetwork(toAsset);
497 String? toAssetTag = _extractTagFromAsset(normalizedToAsset);
498
499 String toAssetBase = toAssetTag != null
500 ? normalizedToAsset.substring(0, normalizedToAsset.length - toAssetTag.length)
501 : normalizedToAsset;
502
503 // Special case for USDT defaulting to ETH tag
504 if (toAssetBase == 'USDT' && toAssetTag == null) {
505 toAssetTag = 'ETH';
506 }
507
508 // Special case for BASE defaulting to BASE tag
509 if (toAssetBase == 'BASE' && toAssetTag == null) {
510 toAssetTag = 'ETH';
511 toAssetBase = 'BASE';
512 }
513
514 final fromCurrency =
515 CryptoCurrency.safeParseCurrencyFromString(fromAssetBase, tag: fromAssetTag);
516 final toCurrency = CryptoCurrency.safeParseCurrencyFromString(toAssetBase, tag: toAssetTag);
517
518 final amount = responseJSON['amount'] as Map<String, dynamic>;
519 final toAmount = responseJSON['toAmount'] as Map<String, dynamic>;
520 final orderId = responseJSON['id'] as String;
521 final depositAmountStr = amount['value'].toString();
522 final parsedAmount = double.tryParse(depositAmountStr);
523
524 if (parsedAmount == null || parsedAmount <= 0) {
525 throw Exception('Invalid deposit amount received from API');
526 }
527
528 final receiveAmount = toAmount['value'] as String;
529 final depositAddress = responseJSON['payInAddress'] as String;
530 final payoutAddress = responseJSON['toAddress'] as String;
531 final refundAddress = responseJSON['fromAddress'] as String;
532 final status = responseJSON['status'] as String;
533 final createdAtString = responseJSON['createdAt'] as String;
534 final createdAt = DateTime.parse(createdAtString).toLocal();
535 final extraId = responseJSON['payInAddressTag'] as String?;
536
537 return Trade(
538 id: orderId,
539 from: fromCurrency,
540 to: toCurrency,
541 provider: description,
542 inputAddress: depositAddress,
543 refundAddress: refundAddress,
544 state: TradeState.deserialize(raw: status),
545 createdAt: createdAt,
546 amount: depositAmountStr,
547 receiveAmount: receiveAmount,
548 payoutAddress: payoutAddress,
549 extraId: extraId,
550 );
551 } catch (e) {
552 printV(e.toString());
553 throw TradeNotCreatedException(description);
554 }
555 }
556
557 // ensure something remains before tag (at least 2 chars)
558 String? _extractTagFromAsset(String asset) {
559 for (final tag in supportedTags) {
560 if (asset.endsWith(tag)) {
561 final prefixLength = asset.length - tag.length;
562 if (prefixLength >= 2) {
563 return tag;
564 }
565 }
566 }
567 return null;
568 }
569 }