dev
dart 466 lines 15.2 KB
Raw
1 import 'dart:convert';
2
3 import 'package:cake_wallet/.secrets.g.dart' as secrets;
4 import 'package:cake_wallet/core/lightning_invoice_service.dart';
5 import 'package:cake_wallet/exchange/exchange_provider_description.dart';
6 import 'package:cake_wallet/exchange/limits.dart';
7 import 'package:cake_wallet/exchange/provider/exchange_provider.dart';
8 import 'package:cake_wallet/exchange/trade.dart';
9 import 'package:cake_wallet/exchange/trade_not_found_exception.dart';
10 import 'package:cake_wallet/exchange/trade_request.dart';
11 import 'package:cake_wallet/exchange/trade_state.dart';
12 import 'package:cake_wallet/wallet_type_utils.dart';
13 import 'package:cw_core/utils/proxy_wrapper.dart';
14 import 'package:cw_core/crypto_currency.dart';
15 import 'package:cw_core/utils/print_verbose.dart';
16 import 'package:cake_wallet/utils/exchange_provider_logger.dart';
17
18 class ExolixExchangeProvider extends ExchangeProvider {
19 ExolixExchangeProvider();
20
21 static final apiKey = isMoneroOnly ? secrets.exolixMoneroApiKey : secrets.exolixCakeWalletApiKey;
22 static const apiBaseUrl = 'exolix.com';
23 static const transactionsPath = '/api/v2/transactions';
24 static const ratePath = '/api/v2/rate';
25
26 @override
27 String get title => 'Exolix';
28
29 @override
30 bool get isAvailable => true;
31
32 @override
33 bool get isEnabled => true;
34
35 @override
36 bool get supportsFixedRate => true;
37
38 @override
39 ExchangeProviderDescription get description => ExchangeProviderDescription.exolix;
40
41 @override
42 Future<bool> checkIsAvailable() async => true;
43
44 @override
45 Future<Limits?> fetchLimits({
46 required CryptoCurrency from,
47 required CryptoCurrency to,
48 required bool isFixedRateMode,
49 }) async {
50 final params = <String, String>{
51 'rateType': _getRateType(isFixedRateMode),
52 'amount': '1',
53 'apiToken': apiKey,
54 };
55
56 if (isFixedRateMode) {
57 params['coinFrom'] = _normalizeCurrency(to);
58 params['coinTo'] = _normalizeCurrency(_overrideFromCryptoCurrency(from));
59 params['networkFrom'] = _networkFor(to);
60 params['networkTo'] = _networkFor(from);
61 } else {
62 params['coinFrom'] = _normalizeCurrency(_overrideFromCryptoCurrency(from));
63 params['coinTo'] = _normalizeCurrency(to);
64 params['networkFrom'] = _networkFor(from);
65 params['networkTo'] = _networkFor(to);
66 }
67
68 // Maximum of 2 attempts to fetch limits
69 for (int i = 0; i < 2; i++) {
70 final uri = Uri.https(apiBaseUrl, ratePath, params);
71 final response = await ProxyWrapper().get(clearnetUri: uri);
72
73 if (response.statusCode == 200) {
74 final responseJSON = json.decode(response.body) as Map<String, dynamic>;
75 final minAmount = responseJSON['minAmount'];
76 final maxAmount = responseJSON['maxAmount'];
77 return Limits(min: _toDouble(minAmount), max: _toDouble(maxAmount));
78 } else if (response.statusCode == 422) {
79 final errorResponse = json.decode(response.body) as Map<String, dynamic>;
80 if (errorResponse.containsKey('minAmount')) {
81 params['amount'] = errorResponse['minAmount'].toString();
82 continue;
83 }
84 throw Exception('Error 422: ${errorResponse['message'] ?? 'Unknown error'}');
85 } else {
86 throw Exception('Unexpected HTTP status: ${response.statusCode}');
87 }
88 }
89
90 throw Exception('Failed to fetch limits after retrying.');
91 }
92
93 @override
94 Future<double> fetchRate(
95 {required CryptoCurrency from,
96 required CryptoCurrency to,
97 required double amount,
98 required bool isFixedRateMode,
99 required bool isReceiveAmount}) async {
100 try {
101 if (amount == 0) return 0.0;
102
103 final params = {
104 'coinFrom': _normalizeCurrency(_overrideFromCryptoCurrency(from)),
105 'coinTo': _normalizeCurrency(to),
106 'networkFrom': _networkFor(from),
107 'networkTo': _networkFor(to),
108 'rateType': _getRateType(isFixedRateMode),
109 'apiToken': apiKey,
110 };
111
112 if (isReceiveAmount)
113 params['withdrawalAmount'] = amount.toString();
114 else
115 params['amount'] = amount.toString();
116
117 final uri = Uri.https(apiBaseUrl, ratePath, params);
118 final response = await ProxyWrapper().get(clearnetUri: uri);
119
120 final responseJSON = json.decode(response.body) as Map<String, dynamic>;
121
122 if (response.statusCode != 200) {
123 final message = responseJSON['message'] as String?;
124
125 ExchangeProviderLogger.logError(
126 provider: description,
127 function: 'fetchRate',
128 error: Exception(message ?? 'Unknown error'),
129 stackTrace: StackTrace.current,
130 requestData: {
131 'from': from.title,
132 'to': to.title,
133 'amount': amount,
134 'isFixedRateMode': isFixedRateMode,
135 'isReceiveAmount': isReceiveAmount,
136 'params': params,
137 'url': uri.toString(),
138 },
139 );
140
141 throw Exception(message);
142 }
143
144 final rate = double.tryParse(responseJSON['rate']?.toString() ?? '') ?? 0.0;
145
146 ExchangeProviderLogger.logSuccess(
147 provider: description,
148 function: 'fetchRate',
149 requestData: {
150 'from': from.title,
151 'to': to.title,
152 'amount': amount,
153 'isFixedRateMode': isFixedRateMode,
154 'isReceiveAmount': isReceiveAmount,
155 'params': params,
156 'url': uri.toString(),
157 },
158 responseData: {
159 'rate': rate,
160 'statusCode': response.statusCode,
161 'responseJSON': responseJSON,
162 },
163 );
164
165 return rate;
166 } catch (e, s) {
167 ExchangeProviderLogger.logError(
168 provider: description,
169 function: 'fetchRate',
170 error: e,
171 stackTrace: s,
172 requestData: {
173 'from': from.title,
174 'to': to.title,
175 'amount': amount,
176 'isFixedRateMode': isFixedRateMode,
177 'isReceiveAmount': isReceiveAmount,
178 },
179 );
180 printV(e.toString());
181 printV(s.toString());
182 return 0.0;
183 }
184 }
185
186 @override
187 Future<Trade> createTrade({
188 required TradeRequest request,
189 required bool isFixedRateMode,
190 required bool isSendAll,
191 }) async {
192 final headers = {'Content-Type': 'application/json'};
193 final body = {
194 'coinFrom': _normalizeCurrency(_overrideFromCryptoCurrency(request.fromCurrency)),
195 'coinTo':
196 _normalizeCurrency(_overrideToCryptoCurrency(request.toCurrency, request.toAddress)),
197 'networkFrom': _networkFor(request.fromCurrency),
198 'networkTo': _networkFor(request.toCurrency),
199 'withdrawalAddress': await _normalizeAddress(request.toAddress),
200 if (request.toAddressExtraId.isNotEmpty) 'withdrawalExtraId': request.toAddressExtraId,
201 'refundAddress': await _normalizeAddress(request.refundAddress),
202 'rateType': _getRateType(isFixedRateMode),
203 'apiToken': apiKey,
204 };
205
206 if (isFixedRateMode)
207 body['withdrawalAmount'] = request.toAmount;
208 else
209 body['amount'] = request.fromAmount;
210
211 final uri = Uri.https(apiBaseUrl, transactionsPath);
212 final response = await ProxyWrapper().post(
213 clearnetUri: uri,
214 headers: headers,
215 body: json.encode(body),
216 );
217
218 if (response.statusCode == 400) {
219 final responseJSON = json.decode(response.body) as Map<String, dynamic>;
220 final errors = responseJSON['error'] as Map<String, String>;
221 final errorMessage = errors.values.join(', ');
222
223 ExchangeProviderLogger.logError(
224 provider: description,
225 function: 'createTrade',
226 error: Exception(errorMessage),
227 stackTrace: StackTrace.current,
228 requestData: {
229 'from': request.fromCurrency.title,
230 'to': request.toCurrency.title,
231 'fromAmount': request.fromAmount,
232 'toAmount': request.toAmount,
233 'toAddress': request.toAddress,
234 'refundAddress': request.refundAddress,
235 'isFixedRateMode': isFixedRateMode,
236 'isSendAll': isSendAll,
237 'body': body,
238 'url': uri.toString(),
239 },
240 );
241
242 throw Exception(errorMessage);
243 }
244
245 if (response.statusCode != 200 && response.statusCode != 201) {
246 ExchangeProviderLogger.logError(
247 provider: description,
248 function: 'createTrade',
249 error: Exception('Unexpected http status: ${response.statusCode}'),
250 stackTrace: StackTrace.current,
251 requestData: {
252 'from': request.fromCurrency.title,
253 'to': request.toCurrency.title,
254 'fromAmount': request.fromAmount,
255 'toAmount': request.toAmount,
256 'toAddress': request.toAddress,
257 'refundAddress': request.refundAddress,
258 'isFixedRateMode': isFixedRateMode,
259 'isSendAll': isSendAll,
260 'body': body,
261 'url': uri.toString(),
262 },
263 );
264 throw Exception('Unexpected http status: ${response.statusCode}');
265 }
266
267 final responseJSON = json.decode(response.body) as Map<String, dynamic>;
268 final id = responseJSON['id'] as String;
269 final inputAddress = responseJSON['depositAddress'] as String;
270 final refundAddress = responseJSON['refundAddress'] as String?;
271 final extraId = responseJSON['depositExtraId'] as String?;
272 final payoutAddress = responseJSON['withdrawalAddress'] as String;
273 final amount = responseJSON['amount'].toString();
274 final receiveAmount = responseJSON['amountTo']?.toString();
275
276 ExchangeProviderLogger.logSuccess(
277 provider: description,
278 function: 'createTrade',
279 requestData: {
280 'from': request.fromCurrency.title,
281 'to': request.toCurrency.title,
282 'fromAmount': request.fromAmount,
283 'toAmount': request.toAmount,
284 'toAddress': request.toAddress,
285 'refundAddress': request.refundAddress,
286 'isFixedRateMode': isFixedRateMode,
287 'isSendAll': isSendAll,
288 'body': body,
289 'url': uri.toString(),
290 },
291 responseData: {
292 'id': id,
293 'inputAddress': inputAddress,
294 'refundAddress': refundAddress,
295 'extraId': extraId,
296 'payoutAddress': payoutAddress,
297 'amount': amount,
298 'receiveAmount': receiveAmount,
299 'statusCode': response.statusCode,
300 'responseJSON': responseJSON,
301 },
302 );
303
304 return Trade(
305 id: id,
306 from: request.fromCurrency,
307 to: request.toCurrency,
308 provider: description,
309 inputAddress: inputAddress,
310 refundAddress: refundAddress,
311 extraId: extraId,
312 createdAt: DateTime.now(),
313 amount: amount,
314 receiveAmount: receiveAmount ?? request.toAmount,
315 state: TradeState.created,
316 payoutAddress: payoutAddress,
317 isSendAll: isSendAll,
318 toAddressExtraId: request.toAddressExtraId,
319 );
320 }
321
322 @override
323 Future<Trade> findTradeById({required String id}) async {
324 final findTradeByIdPath = '$transactionsPath/$id';
325 final uri = Uri.https(apiBaseUrl, findTradeByIdPath);
326 final response = await ProxyWrapper().get(clearnetUri: uri);
327
328 if (response.statusCode == 404) throw TradeNotFoundException(id, provider: description);
329
330 if (response.statusCode == 400) {
331 final responseJSON = json.decode(response.body) as Map<String, dynamic>;
332 final errors = responseJSON['errors'] as Map<String, String>;
333 final errorMessage = errors.values.join(', ');
334
335 throw TradeNotFoundException(id, provider: description, description: errorMessage);
336 }
337
338 if (response.statusCode != 200)
339 throw Exception('Unexpected http status: ${response.statusCode}');
340
341 final responseJSON = json.decode(response.body) as Map<String, dynamic>;
342
343 // Parsing 'from' currency
344 final coinFrom = responseJSON['coinFrom']['coinCode'] as String;
345 final coinFromNetwork = responseJSON['coinFrom']['network'] as String?;
346 final _normalizedFromNetwork = _normalizeNetworkType(coinFromNetwork ?? '');
347 final fromTag =
348 coinFrom.toUpperCase() == _normalizedFromNetwork.toUpperCase() ? null : coinFromNetwork;
349 final from = CryptoCurrency.safeParseCurrencyFromString(coinFrom, tag: fromTag);
350
351 // Parsing 'to' currency
352 final coinTo = responseJSON['coinTo']['coinCode'] as String;
353 final coinToNetwork = responseJSON['coinTo']['network'] as String?;
354 final _normalizedToNetwork = _normalizeNetworkType(coinToNetwork ?? '');
355 final toTag = coinTo.toUpperCase() == _normalizedToNetwork.toUpperCase() ? null : coinToNetwork;
356 final to = CryptoCurrency.safeParseCurrencyFromString(coinTo, tag: toTag);
357
358 final inputAddress = responseJSON['depositAddress'] as String;
359 final amount = responseJSON['amount'].toString();
360 final status = responseJSON['status'] as String;
361 final extraId = responseJSON['depositExtraId'] as String?;
362 final outputTransaction = responseJSON['hashOut']['hash'] as String?;
363 final payoutAddress = responseJSON['withdrawalAddress'] as String;
364
365 return Trade(
366 id: id,
367 from: from,
368 to: to,
369 provider: description,
370 inputAddress: inputAddress,
371 amount: amount,
372 state: TradeState.deserialize(raw: _prepareStatus(status)),
373 extraId: extraId,
374 outputTransaction: outputTransaction,
375 payoutAddress: payoutAddress,
376 );
377 }
378
379 String _getRateType(bool isFixedRate) => isFixedRate ? 'fixed' : 'float';
380
381 String _prepareStatus(String status) {
382 switch (status) {
383 case 'deleted':
384 case 'error':
385 return 'overdue';
386 default:
387 return status;
388 }
389 }
390
391 String _networkFor(CryptoCurrency currency) {
392 switch (currency) {
393 case CryptoCurrency.arb:
394 return 'ARBITRUM';
395 case CryptoCurrency.btcln:
396 return 'LIGHTNING';
397 default:
398 return currency.tag != null ? _normalizeTag(currency.tag!) : currency.title;
399 }
400 }
401
402 String _normalizeNetworkType(String network) {
403 return switch (network.toUpperCase()) {
404 'ARBITRUM' => 'ARB',
405 _ => network,
406 };
407 }
408
409 CryptoCurrency _overrideFromCryptoCurrency(CryptoCurrency currency) {
410 if (currency == CryptoCurrency.zec)
411 return CryptoCurrency.zaddr; // Sending is always shielded zcash
412 return currency;
413 }
414
415 CryptoCurrency _overrideToCryptoCurrency(CryptoCurrency currency, String address) {
416 if (RegExp(r'u1[a-zA-Z0-9]{100,300}').hasMatch(address) && currency == CryptoCurrency.zec)
417 return CryptoCurrency.zaddr; // If the user pastes a unified address use shielded zcash
418 return currency;
419 }
420
421 String _normalizeCurrency(CryptoCurrency currency) {
422 switch (currency) {
423 case CryptoCurrency.nano:
424 return 'XNO';
425 case CryptoCurrency.bttc:
426 return 'BTT';
427 case CryptoCurrency.zec:
428 return 'ZEC';
429 case CryptoCurrency.zaddr:
430 return 'ZEC-SHIELDED';
431 default:
432 return currency.title;
433 }
434 }
435
436 String _normalizeTag(String tag) {
437 switch (tag) {
438 case 'POLY':
439 return 'Polygon';
440 case 'ARB':
441 return 'Arbitrum';
442 default:
443 return tag;
444 }
445 }
446
447 Future<String> _normalizeAddress(String address) async {
448 if (address.startsWith('bitcoincash:')) return address.replaceFirst('bitcoincash:', '');
449
450 // Lightning addresses
451 if (address.contains("@")) return await getBolt11FromLightingAddress(address) ?? address;
452
453 return address;
454 }
455
456 static double? _toDouble(dynamic value) {
457 if (value is int) {
458 return value.toDouble();
459 } else if (value is double) {
460 return value;
461 } else if (value is String) {
462 return double.tryParse(value);
463 }
464 return null;
465 }
466 }