dev
dart 588 lines 19.3 KB
Raw
1 import 'dart:convert';
2
3 import 'package:cake_wallet/.secrets.g.dart' as secrets;
4 import 'package:cake_wallet/exchange/exchange_provider_description.dart';
5 import 'package:cake_wallet/exchange/limits.dart';
6 import 'package:cake_wallet/exchange/provider/exchange_provider.dart';
7 import 'package:cake_wallet/exchange/trade.dart';
8 import 'package:cake_wallet/exchange/trade_request.dart';
9 import 'package:cake_wallet/exchange/trade_state.dart';
10 import 'package:cake_wallet/wallet_type_utils.dart';
11 import 'package:cw_core/utils/proxy_wrapper.dart';
12 import 'package:cw_core/crypto_currency.dart';
13 import 'package:cw_core/utils/print_verbose.dart';
14 import 'package:cake_wallet/utils/exchange_provider_logger.dart';
15
16 class TrocadorExchangeProvider extends ExchangeProvider {
17 TrocadorExchangeProvider({this.useTorOnly = false, this.providerStates = const {}})
18 : _lastUsedRateId = '',
19 _provider = [];
20
21 bool useTorOnly;
22 Map<String, bool> providerStates;
23
24 static const List<String> availableProviders = [
25 'Swapter',
26 'StealthEx',
27 'Simpleswap',
28 'Swapuz',
29 'ChangeNow',
30 'Changehero',
31 'FixedFloat',
32 'LetsExchange',
33 'Exolix',
34 'Godex',
35 'Exch',
36 'CoinCraddle',
37 'Alfacash',
38 'LocalMonero',
39 'XChange',
40 'NeroSwap',
41 'Changee',
42 'BitcoinVN',
43 'EasyBit',
44 'WizardSwap',
45 'Quantex',
46 'SwapSpace',
47 ];
48
49 static final apiKey = isMoneroOnly ? secrets.trocadorMoneroApiKey : secrets.trocadorApiKey;
50 static const clearNetAuthority = 'api.trocador.app';
51 static const onionApiAuthority = clearNetAuthority;
52 // static const onionApiAuthority = 'trocadorfyhlu27aefre5u7zri66gudtzdyelymftvr4yjwcxhfaqsid.onion';
53 static const markup = secrets.trocadorExchangeMarkup;
54 static const newRatePath = '/new_rate';
55 static const createTradePath = '/new_trade';
56 static const tradePath = '/trade';
57 static const coinPath = '/coin';
58 static const providersListPath = '/exchanges';
59
60 String _lastUsedRateId;
61 List<dynamic> _provider;
62
63 @override
64 String get title => 'Trocador';
65
66 @override
67 bool get isAvailable => true;
68
69 @override
70 bool get isEnabled => true;
71
72 @override
73 bool get supportsFixedRate => true;
74
75 @override
76 bool get supportsOnionAddress => true;
77
78 @override
79 ExchangeProviderDescription get description => ExchangeProviderDescription.trocador;
80
81 @override
82 Future<bool> checkIsAvailable() async => true;
83
84 @override
85 Future<Limits?> fetchLimits(
86 {required CryptoCurrency from,
87 required CryptoCurrency to,
88 required bool isFixedRateMode}) async {
89 final params = {
90 'ticker': _normalizeCurrency(from),
91 'name': from.name,
92 };
93
94 final uri = await _getUri(coinPath, params);
95 final response = await ProxyWrapper().get(clearnetUri: uri, headers: {'API-Key': apiKey});
96
97 if (response.statusCode != 200)
98 throw Exception('Unexpected http status: ${response.statusCode}');
99
100 final responseJSON = json.decode(response.body) as List<dynamic>;
101
102 if (responseJSON.isEmpty) throw Exception('No data');
103
104 final coinJson = responseJSON.first as Map<String, dynamic>;
105
106 final min = double.tryParse(coinJson['minimum']?.toString() ?? '');
107 final max = double.tryParse(coinJson['maximum']?.toString() ?? '');
108 if (max == 0) return null;
109 return Limits(min: min, max: max);
110 }
111
112 @override
113 Future<double> fetchRate(
114 {required CryptoCurrency from,
115 required CryptoCurrency to,
116 required double amount,
117 required bool isFixedRateMode,
118 required bool isReceiveAmount}) async {
119 try {
120 if (amount == 0) return 0.0;
121
122 final params = <String, String>{
123 'ticker_from': _normalizeCurrency(from),
124 'ticker_to': _normalizeCurrency(to),
125 'network_from': _networkFor(from),
126 'network_to': _networkFor(to),
127 if (!isFixedRateMode) 'amount_from': amount.toString(),
128 if (isFixedRateMode) 'amount_to': amount.toString(),
129 'payment': isFixedRateMode ? 'True' : 'False',
130 'min_kycrating': 'C',
131 'markup': markup,
132 };
133
134 final uri = await _getUri(newRatePath, params);
135 final response = await ProxyWrapper().get(clearnetUri: uri, headers: {'API-Key': apiKey});
136
137 final responseJSON = json.decode(response.body) as Map<String, dynamic>;
138 final fromAmount = double.tryParse(responseJSON['amount_from']?.toString() ?? '') ?? 0.0;
139 final toAmount = double.tryParse(responseJSON['amount_to']?.toString() ?? '') ?? 0.0;
140
141 if (fromAmount <= 0 || toAmount <= 0) return 0.0;
142
143 final quotesData = responseJSON['quotes'];
144 final rateId = _safeString(responseJSON, 'trade_id');
145
146 if (quotesData == null || quotesData is! Map<String, dynamic>) {
147 return 0.0;
148 }
149 final quotes = (quotesData['quotes'] as List?) ?? [];
150 _provider = quotes
151 .where((quote) => providerStates[quote['provider']] != false)
152 .map((quote) => quote['provider'])
153 .toList();
154
155 if (_provider.isEmpty) {
156 ExchangeProviderLogger.logError(
157 provider: description,
158 function: 'fetchRate',
159 error: Exception('No enabled providers found for the selected trade.'),
160 stackTrace: StackTrace.current,
161 requestData: {
162 'from': from.title,
163 'to': to.title,
164 'amount': amount,
165 'isFixedRateMode': isFixedRateMode,
166 'isReceiveAmount': isReceiveAmount,
167 'params': params,
168 'url': uri.toString(),
169 },
170 );
171 throw Exception('No enabled providers found for the selected trade.');
172 }
173
174 if (rateId.isNotEmpty) _lastUsedRateId = rateId;
175
176 final rate = isReceiveAmount ? (amount / fromAmount) : (toAmount / amount);
177
178 ExchangeProviderLogger.logSuccess(
179 provider: description,
180 function: 'fetchRate',
181 requestData: {
182 'from': from.title,
183 'to': to.title,
184 'amount': amount,
185 'isFixedRateMode': isFixedRateMode,
186 'isReceiveAmount': isReceiveAmount,
187 'params': params,
188 'url': uri.toString(),
189 },
190 responseData: {
191 'fromAmount': fromAmount,
192 'toAmount': toAmount,
193 'rate': rate,
194 'rateId': rateId,
195 'provider': _provider.first,
196 'statusCode': response.statusCode,
197 'responseJSON': responseJSON,
198 },
199 );
200
201 return rate;
202 } catch (e, s) {
203 ExchangeProviderLogger.logError(
204 provider: description,
205 function: 'fetchRate',
206 error: e,
207 stackTrace: s,
208 requestData: {
209 'from': from.title,
210 'to': to.title,
211 'amount': amount,
212 'isFixedRateMode': isFixedRateMode,
213 'isReceiveAmount': isReceiveAmount,
214 },
215 );
216 printV(e.toString());
217 return 0.0;
218 }
219 }
220
221 @override
222 Future<Trade> createTrade({
223 required TradeRequest request,
224 required bool isFixedRateMode,
225 required bool isSendAll,
226 }) async {
227 final params = {
228 'ticker_from': _normalizeCurrency(request.fromCurrency),
229 'ticker_to': _normalizeCurrency(request.toCurrency),
230 'network_from': _networkFor(request.fromCurrency),
231 'network_to': _networkFor(request.toCurrency),
232 'payment': isFixedRateMode ? 'True' : 'False',
233 'min_kycrating': 'C',
234 'markup': markup,
235 if (!isFixedRateMode) 'amount_from': request.fromAmount,
236 if (isFixedRateMode) 'amount_to': request.toAmount,
237 'address': request.toAddress,
238 if (request.toAddressExtraId.isNotEmpty) 'address_memo': request.toAddressExtraId,
239 'refund': request.refundAddress,
240 'refund_memo': '0',
241 };
242
243 if (isFixedRateMode) {
244 await fetchRate(
245 from: request.fromCurrency,
246 to: request.toCurrency,
247 amount: double.tryParse(request.toAmount) ?? 0,
248 isFixedRateMode: true,
249 isReceiveAmount: true,
250 );
251 params['id'] = _lastUsedRateId;
252 }
253
254 if (_provider.isEmpty || _provider.first == null || _provider.first.toString().isEmpty) {
255 ExchangeProviderLogger.logError(
256 provider: description,
257 function: 'createTrade',
258 error: Exception('No available provider is enabled'),
259 stackTrace: StackTrace.current,
260 requestData: {
261 'from': request.fromCurrency.title,
262 'to': request.toCurrency.title,
263 'fromAmount': request.fromAmount,
264 'toAmount': request.toAmount,
265 'toAddress': request.toAddress,
266 'refundAddress': request.refundAddress,
267 'isFixedRateMode': isFixedRateMode,
268 'isSendAll': isSendAll,
269 'params': params,
270 },
271 );
272 throw Exception('No available provider is enabled');
273 }
274
275 params['provider'] = _provider.first.toString();
276
277 final uri = await _getUri(createTradePath, params);
278 final response = await ProxyWrapper().get(clearnetUri: uri, headers: {'API-Key': apiKey});
279
280 if (response.statusCode == 400) {
281 final responseJSON = json.decode(response.body) as Map<String, dynamic>;
282 final error = _safeString(responseJSON, 'error', 'Unknown error');
283 final message = _safeString(responseJSON, 'message');
284
285 ExchangeProviderLogger.logError(
286 provider: description,
287 function: 'createTrade',
288 error: Exception('${error}\n$message'),
289 stackTrace: StackTrace.current,
290 requestData: {
291 'from': request.fromCurrency.title,
292 'to': request.toCurrency.title,
293 'fromAmount': request.fromAmount,
294 'toAmount': request.toAmount,
295 'toAddress': request.toAddress,
296 'refundAddress': request.refundAddress,
297 'isFixedRateMode': isFixedRateMode,
298 'isSendAll': isSendAll,
299 'params': params,
300 'url': uri.toString(),
301 },
302 );
303
304 throw Exception('${error}\n$message');
305 }
306
307 if (response.statusCode != 200) {
308 ExchangeProviderLogger.logError(
309 provider: description,
310 function: 'createTrade',
311 error: Exception('Unexpected http status: ${response.statusCode}'),
312 stackTrace: StackTrace.current,
313 requestData: {
314 'from': request.fromCurrency.title,
315 'to': request.toCurrency.title,
316 'fromAmount': request.fromAmount,
317 'toAmount': request.toAmount,
318 'toAddress': request.toAddress,
319 'refundAddress': request.refundAddress,
320 'isFixedRateMode': isFixedRateMode,
321 'isSendAll': isSendAll,
322 'params': params,
323 'url': uri.toString(),
324 },
325 );
326 throw Exception('Unexpected http status: ${response.statusCode}');
327 }
328
329 final responseJSON = json.decode(response.body) as Map<String, dynamic>;
330 final id = _safeString(responseJSON, 'trade_id');
331 final inputAddress = _safeString(responseJSON, 'address_provider');
332 final refundAddress = _safeString(responseJSON, 'refund_address');
333 final status = _safeString(responseJSON, 'status');
334 final payoutAddress = _safeString(responseJSON, 'address_user');
335 final date = _safeString(responseJSON, 'date');
336 final password = _safeString(responseJSON, 'password');
337 final providerId = _safeString(responseJSON, 'id_provider');
338 final providerName = _safeString(responseJSON, 'provider');
339 final amount = _safeString(responseJSON, 'amount_from');
340 final receiveAmount = _safeString(responseJSON, 'amount_to');
341 final addressProviderMemo = _safeString(responseJSON, 'address_provider_memo');
342
343 ExchangeProviderLogger.logSuccess(
344 provider: description,
345 function: 'createTrade',
346 requestData: {
347 'from': request.fromCurrency.title,
348 'to': request.toCurrency.title,
349 'fromAmount': request.fromAmount,
350 'toAmount': request.toAmount,
351 'toAddress': request.toAddress,
352 'refundAddress': request.refundAddress,
353 'isFixedRateMode': isFixedRateMode,
354 'isSendAll': isSendAll,
355 'params': params,
356 'url': uri.toString(),
357 },
358 responseData: {
359 'id': id,
360 'inputAddress': inputAddress,
361 'refundAddress': refundAddress,
362 'status': status,
363 'payoutAddress': payoutAddress,
364 'date': date,
365 'password': password,
366 'providerId': providerId,
367 'providerName': providerName,
368 'amount': amount,
369 'receiveAmount': receiveAmount,
370 'addressProviderMemo': addressProviderMemo,
371 'statusCode': response.statusCode,
372 'responseJSON': responseJSON,
373 },
374 );
375
376 final amountToTrade = amount.isEmpty ? request.fromAmount : amount;
377 final receiveAmountToTrade = receiveAmount.isEmpty ? request.toAmount : receiveAmount;
378
379 return Trade(
380 id: id,
381 from: request.fromCurrency,
382 to: request.toCurrency,
383 provider: description,
384 inputAddress: inputAddress,
385 refundAddress: refundAddress,
386 state: TradeState.deserialize(raw: status),
387 password: password,
388 providerId: providerId,
389 providerName: providerName,
390 createdAt: DateTime.tryParse(date)?.toLocal(),
391 amount: amountToTrade,
392 receiveAmount: receiveAmountToTrade,
393 payoutAddress: payoutAddress,
394 isSendAll: isSendAll,
395 extraId: addressProviderMemo,
396 toAddressExtraId: request.toAddressExtraId,
397 );
398 }
399
400 @override
401 Future<Trade> findTradeById({required String id}) async {
402 final uri = await _getUri(tradePath, {'id': id});
403 return ProxyWrapper()
404 .get(clearnetUri: uri, headers: {'API-Key': apiKey}).then((response) async {
405 if (response.statusCode != 200)
406 throw Exception('Unexpected http status: ${response.statusCode}');
407
408 final responseListJson = json.decode(response.body) as List;
409 final responseJSON = responseListJson.first as Map<String, dynamic>;
410 final id = _safeString(responseJSON, 'trade_id');
411 final payoutAddress = _safeString(responseJSON, 'address_user');
412 final refundAddress = _safeString(responseJSON, 'refund_address');
413 final inputAddress = _safeString(responseJSON, 'address_provider');
414 final fromAmount = responseJSON['amount_from']?.toString() ?? '0';
415 final password = _safeString(responseJSON, 'password');
416 final providerId = _safeString(responseJSON, 'id_provider');
417 final providerName = _safeString(responseJSON, 'provider');
418 final memoVal = _safeString(responseJSON, 'address_provider_memo');
419 final addressProviderMemo = memoVal.isEmpty ? null : memoVal;
420
421 final fromCurrency = _safeString(responseJSON, 'ticker_from');
422 final fromNetworkVal = _safeString(responseJSON, 'network_from');
423 final fromNetwork = fromNetworkVal.isEmpty ? null : fromNetworkVal;
424 final _normalizedFromNetwork = _normalizeNetworkType(fromNetwork ?? '');
425 final fromTag = _normalizedFromNetwork.isEmpty ||
426 _normalizedFromNetwork == fromCurrency.toUpperCase() ||
427 _normalizedFromNetwork == 'Mainnet'
428 ? null
429 : _normalizedFromNetwork;
430
431 final from = CryptoCurrency.safeParseCurrencyFromString(fromCurrency, tag: fromTag);
432
433 final toCurrency = _safeString(responseJSON, 'ticker_to');
434 final networkToVal = _safeString(responseJSON, 'network_to');
435 final networkTo = networkToVal.isEmpty ? null : networkToVal;
436 final _normalizedToNetwork = _normalizeNetworkType(networkTo ?? '');
437 final toTag = _normalizedToNetwork.isEmpty ||
438 _normalizedToNetwork == toCurrency.toUpperCase() ||
439 _normalizedFromNetwork == 'Mainnet'
440 ? null
441 : _normalizedToNetwork;
442 final to = CryptoCurrency.safeParseCurrencyFromString(toCurrency, tag: toTag);
443
444 return Trade(
445 id: id,
446 from: from,
447 to: to,
448 provider: description,
449 inputAddress: inputAddress,
450 refundAddress: refundAddress,
451 createdAt: DateTime.tryParse(_safeString(responseJSON, 'date')),
452 amount: fromAmount,
453 state: TradeState.deserialize(raw: _safeString(responseJSON, 'status')),
454 payoutAddress: payoutAddress,
455 password: password,
456 providerId: providerId,
457 providerName: providerName,
458 extraId: addressProviderMemo,
459 );
460 });
461 }
462
463 Future<List<TrocadorPartners>> fetchProviders() async {
464 final uri = await _getUri(providersListPath, {'api_key': apiKey});
465 final response = await ProxyWrapper().get(clearnetUri: uri);
466
467 if (response.statusCode != 200)
468 throw Exception('Unexpected http status: ${response.statusCode}');
469
470 final responseJSON = json.decode(response.body) as Map<String, dynamic>;
471
472 final providersJsonList = responseJSON['list'] as List<dynamic>;
473 final filteredProvidersList = providersJsonList
474 .map((providerJson) => TrocadorPartners.fromJson(providerJson as Map<String, dynamic>))
475 .where((provider) => provider.rating != 'D')
476 .toList();
477 filteredProvidersList.sort((a, b) => a.rating.compareTo(b.rating));
478 return filteredProvidersList;
479 }
480
481 String _networkFor(CryptoCurrency currency) {
482 switch (currency) {
483 case CryptoCurrency.eth:
484 return 'ERC20';
485 case CryptoCurrency.maticpoly:
486 return 'Mainnet';
487 case CryptoCurrency.usdcpoly:
488 case CryptoCurrency.usdtPoly:
489 case CryptoCurrency.usdcEPoly:
490 return 'MATIC';
491 case CryptoCurrency.zec:
492 return 'Mainnet';
493 case CryptoCurrency.arb:
494 return 'Mainnet';
495 default:
496 return currency.tag != null ? _normalizeTag(currency.tag!) : 'Mainnet';
497 }
498 }
499
500 String _normalizeCurrency(CryptoCurrency currency) {
501 switch (currency) {
502 case CryptoCurrency.zec:
503 return 'zec';
504 case CryptoCurrency.usdcEPoly:
505 return 'usdce';
506 default:
507 return currency.title.toLowerCase();
508 }
509 }
510
511 String _normalizeTag(String tag) {
512 if (tag.contains('ARB')) {
513 return 'Arbitrum';
514 }
515
516 switch (tag) {
517 case 'ETH':
518 return 'ERC20';
519 case 'TRX':
520 return 'TRC20';
521 case 'LN':
522 return 'Lightning';
523 case 'BSC':
524 return 'BEP20';
525 default:
526 return tag.toLowerCase();
527 }
528 }
529
530 String _normalizeNetworkType(String network) {
531 return switch (network.toUpperCase()) {
532 'ERC20' => 'ETH',
533 'TRC20' => 'TRX',
534 'BEP20' => 'BSC',
535 'LIGHTNING' => 'LN',
536 'MATIC' => 'POL',
537 _ => network,
538 };
539 }
540
541 Future<Uri> _getUri(String path, Map<String, String> queryParams) async {
542 final uri = Uri.https(onionApiAuthority, path, queryParams);
543
544 if (useTorOnly) return uri;
545
546 try {
547 await ProxyWrapper().get(clearnetUri: uri);
548
549 return uri;
550 } catch (e) {
551 return Uri.https(clearNetAuthority, path, queryParams);
552 }
553 }
554
555 /// Safe string extraction from API response. Handles different data types.
556 static String _safeString(Map<String, dynamic> m, String key, [String nullError = '']) {
557 final v = m[key];
558 if (v == null) return nullError;
559 if (v is String) return v;
560 return v.toString();
561 }
562 }
563
564 class TrocadorPartners {
565 final String name;
566 final String rating;
567 final double? insurance;
568 final bool? enabledMarkup;
569 final double? eta;
570
571 TrocadorPartners({
572 required this.name,
573 required this.rating,
574 required this.insurance,
575 required this.enabledMarkup,
576 required this.eta,
577 });
578
579 factory TrocadorPartners.fromJson(Map<String, dynamic> json) {
580 return TrocadorPartners(
581 name: json['name'] as String? ?? '',
582 rating: json['rating'] as String? ?? 'N/A',
583 insurance: json['insurance'] as double?,
584 enabledMarkup: json['enabledmarkup'] as bool?,
585 eta: json['eta'] as double?,
586 );
587 }
588 }