dev
dart 448 lines 15.1 KB
Raw
1 import 'dart:convert';
2 import 'dart:developer';
3
4 import 'package:cake_wallet/.secrets.g.dart' as secrets;
5 import 'package:cake_wallet/exchange/provider/exchange_provider.dart';
6 import 'package:cake_wallet/exchange/exchange_provider_description.dart';
7 import 'package:cake_wallet/exchange/limits.dart';
8 import 'package:cake_wallet/exchange/trade.dart';
9 import 'package:cake_wallet/exchange/trade_not_created_exception.dart';
10 import 'package:cake_wallet/exchange/trade_request.dart';
11 import 'package:cake_wallet/exchange/trade_state.dart';
12 import 'package:cw_core/utils/proxy_wrapper.dart';
13 import 'package:cw_core/crypto_currency.dart';
14 import 'package:cw_core/utils/print_verbose.dart';
15 import 'package:cake_wallet/utils/exchange_provider_logger.dart';
16
17 class LetsExchangeExchangeProvider extends ExchangeProvider {
18 LetsExchangeExchangeProvider();
19
20 static const apiKey = secrets.letsExchangeBearerToken;
21 static const _baseUrl = 'api.letsexchange.io';
22 static const _infoPath = '/api/v1/info';
23 static const _infoRevertPath = '/api/v1/info-revert';
24 static const _createTransactionPath = '/api/v1/transaction';
25 static const _createTransactionRevertPath = '/api/v1/transaction-revert';
26 static const _getTransactionPath = '/api/v1/transaction';
27
28 static const _affiliateId = secrets.letsExchangeAffiliateId;
29
30 @override
31 String get title => 'LetsExchange';
32
33 @override
34 bool get isAvailable => true;
35
36 @override
37 bool get isEnabled => true;
38
39 @override
40 bool get supportsFixedRate => true;
41
42 @override
43 ExchangeProviderDescription get description => ExchangeProviderDescription.letsExchange;
44
45 @override
46 Future<bool> checkIsAvailable() async => true;
47
48 @override
49 Future<Limits?> fetchLimits(
50 {required CryptoCurrency from,
51 required CryptoCurrency to,
52 required bool isFixedRateMode}) async {
53 final networkFrom = _getNetworkType(from);
54 final networkTo = _getNetworkType(to);
55
56 try {
57 final params = {
58 'from': from.title,
59 'to': to.title,
60 if (networkFrom != null) 'network_from': networkFrom,
61 if (networkTo != null) 'network_to': networkTo,
62 'amount': '1',
63 'affiliate_id': _affiliateId,
64 'float': isFixedRateMode ? 'false' : 'true',
65 };
66
67 final responseJSON = await _getInfo(params, isFixedRateMode);
68 final min = double.tryParse(responseJSON['min_amount'] as String);
69 final max = double.tryParse(responseJSON['max_amount'] as String);
70 return Limits(min: min, max: max);
71 } catch (e) {
72 log(e.toString());
73 throw Exception('Failed to fetch limits');
74 }
75 }
76
77 @override
78 Future<double> fetchRate(
79 {required CryptoCurrency from,
80 required CryptoCurrency to,
81 required double amount,
82 required bool isFixedRateMode,
83 required bool isReceiveAmount}) async {
84 final networkFrom = _getNetworkType(from);
85 final networkTo = _getNetworkType(to);
86 try {
87 final params = {
88 'from': from.title,
89 'to': to.title,
90 if (networkFrom != null) 'network_from': networkFrom,
91 if (networkTo != null) 'network_to': networkTo,
92 'amount': amount.toString(),
93 'affiliate_id': _affiliateId,
94 'float': isFixedRateMode ? 'false' : 'true',
95 };
96
97 final responseJSON = await _getInfo(params, isFixedRateMode);
98
99 final amountToGet = double.tryParse(responseJSON['amount'] as String) ?? 0.0;
100
101 if (amountToGet == 0.0) return 0.0;
102
103 final rate = isFixedRateMode ? amount / amountToGet : amountToGet / amount;
104
105 ExchangeProviderLogger.logSuccess(
106 provider: description,
107 function: 'fetchRate',
108 requestData: {
109 'from': from.title,
110 'to': to.title,
111 'amount': amount,
112 'isFixedRateMode': isFixedRateMode,
113 'isReceiveAmount': isReceiveAmount,
114 'networkFrom': networkFrom,
115 'networkTo': networkTo,
116 'params': params,
117 },
118 responseData: {
119 'amountToGet': amountToGet,
120 'rate': rate,
121 'responseJSON': responseJSON,
122 },
123 );
124
125 return rate;
126 } catch (e, s) {
127 ExchangeProviderLogger.logError(
128 provider: description,
129 function: 'fetchRate',
130 error: e,
131 stackTrace: s,
132 requestData: {
133 'from': from.title,
134 'to': to.title,
135 'amount': amount,
136 'isFixedRateMode': isFixedRateMode,
137 'isReceiveAmount': isReceiveAmount,
138 'networkFrom': networkFrom,
139 'networkTo': networkTo,
140 },
141 );
142 printV(e.toString());
143 return 0.0;
144 }
145 }
146
147 @override
148 Future<Trade> createTrade(
149 {required TradeRequest request,
150 required bool isFixedRateMode,
151 required bool isSendAll}) async {
152 final networkFrom = _getNetworkType(request.fromCurrency);
153 final networkTo = _getNetworkType(request.toCurrency);
154 try {
155 final params = {
156 'from': request.fromCurrency.title,
157 'to': request.toCurrency.title,
158 if (networkFrom != null) 'network_from': networkFrom,
159 if (networkTo != null) 'network_to': networkTo,
160 'amount': isFixedRateMode ? request.toAmount.toString() : request.fromAmount.toString(),
161 'affiliate_id': _affiliateId,
162 'float': isFixedRateMode ? 'false' : 'true',
163 };
164
165 final responseInfoJSON = await _getInfo(params, isFixedRateMode);
166 final rateId = responseInfoJSON['rate_id'] as String;
167
168 final withdrawalAddress = _normalizeBchAddress(request.toAddress);
169 final returnAddress = _normalizeBchAddress(request.refundAddress);
170
171 final tradeParams = {
172 'coin_from': request.fromCurrency.title,
173 'coin_to': request.toCurrency.title,
174 if (!isFixedRateMode) 'deposit_amount': request.fromAmount.toString(),
175 'withdrawal': withdrawalAddress,
176 if (isFixedRateMode) 'withdrawal_amount': request.toAmount.toString(),
177 'withdrawal_extra_id': request.toAddressExtraId,
178 'return': returnAddress,
179 'rate_id': rateId,
180 if (networkFrom != null) 'network_from': networkFrom,
181 if (networkTo != null) 'network_to': networkTo,
182 'affiliate_id': _affiliateId,
183 'float': isFixedRateMode ? 'false' : 'true',
184 };
185
186 final headers = {
187 'Content-Type': 'application/json',
188 'Accept': 'application/json',
189 'Authorization': apiKey
190 };
191
192 final uri = Uri.https(
193 _baseUrl, isFixedRateMode ? _createTransactionRevertPath : _createTransactionPath);
194 final response = await ProxyWrapper().post(
195 clearnetUri: uri,
196 headers: headers,
197 body: json.encode(tradeParams),
198 );
199
200 if (response.statusCode != 200) {
201 ExchangeProviderLogger.logError(
202 provider: description,
203 function: 'createTrade',
204 error: Exception('LetsExchange create trade failed: ${response.body}'),
205 stackTrace: StackTrace.current,
206 requestData: {
207 'from': request.fromCurrency.title,
208 'to': request.toCurrency.title,
209 'fromAmount': request.fromAmount,
210 'toAmount': request.toAmount,
211 'toAddress': request.toAddress,
212 'refundAddress': request.refundAddress,
213 'isFixedRateMode': isFixedRateMode,
214 'isSendAll': isSendAll,
215 'networkFrom': networkFrom,
216 'networkTo': networkTo,
217 'tradeParams': tradeParams,
218 'url': uri.toString(),
219 },
220 );
221 throw Exception('LetsExchange create trade failed: ${response.body}');
222 }
223 final responseJSON = json.decode(response.body) as Map<String, dynamic>;
224 final id = responseJSON['transaction_id'] as String;
225 final from = responseJSON['coin_from'] as String;
226 final to = responseJSON['coin_to'] as String;
227 final payoutAddress = responseJSON['withdrawal'] as String;
228 final depositAddress = responseJSON['deposit'] as String;
229 final refundAddress = responseJSON['return'] as String;
230 final depositAmount = responseJSON['deposit_amount'] as String;
231 final receiveAmount = responseJSON['withdrawal_amount'] as String;
232 final status = responseJSON['status'] as String;
233
234 // We ignore the created_at from response and use DateTime.now() instead
235 final createdAtString = responseJSON['created_at'] as String;
236 final expiredAtTimestamp = responseJSON['expired_at'] as int;
237 final extraId = responseJSON['deposit_extra_id'] as String?;
238
239 final createdAt = DateTime.now();
240 final expiredAt = createdAt.add(Duration(minutes: 30));
241
242 CryptoCurrency fromCurrency;
243 if (request.fromCurrency.tag != null && request.fromCurrency.title == from) {
244 fromCurrency = request.fromCurrency;
245 } else {
246 fromCurrency = CryptoCurrency.fromString(from);
247 }
248
249 CryptoCurrency toCurrency;
250 if (request.toCurrency.tag != null && request.toCurrency.title == to) {
251 toCurrency = request.toCurrency;
252 } else {
253 toCurrency = CryptoCurrency.fromString(to);
254 }
255
256 ExchangeProviderLogger.logSuccess(
257 provider: description,
258 function: 'createTrade',
259 requestData: {
260 'from': request.fromCurrency.title,
261 'to': request.toCurrency.title,
262 'fromAmount': request.fromAmount,
263 'toAmount': request.toAmount,
264 'toAddress': request.toAddress,
265 'refundAddress': request.refundAddress,
266 'isFixedRateMode': isFixedRateMode,
267 'isSendAll': isSendAll,
268 'networkFrom': networkFrom,
269 'networkTo': networkTo,
270 'tradeParams': tradeParams,
271 'url': uri.toString(),
272 },
273 responseData: {
274 'id': id,
275 'from': from,
276 'to': to,
277 'depositAddress': depositAddress,
278 'payoutAddress': payoutAddress,
279 'refundAddress': refundAddress,
280 'depositAmount': depositAmount,
281 'receiveAmount': receiveAmount,
282 'status': status,
283 'createdAt': createdAtString,
284 'expiredAt': expiredAtTimestamp,
285 'extraId': extraId,
286 'statusCode': response.statusCode,
287 },
288 );
289
290 return Trade(
291 id: id,
292 from: fromCurrency,
293 to: toCurrency,
294 provider: description,
295 inputAddress: depositAddress,
296 payoutAddress: payoutAddress,
297 refundAddress: refundAddress,
298 amount: depositAmount,
299 receiveAmount: receiveAmount,
300 state: TradeState.deserialize(raw: status),
301 createdAt: createdAt,
302 expiredAt: expiredAt,
303 extraId: extraId,
304 isSendAll: isSendAll,
305 toAddressExtraId: request.toAddressExtraId,
306 );
307 } catch (e, s) {
308 ExchangeProviderLogger.logError(
309 provider: description,
310 function: 'createTrade',
311 error: e,
312 stackTrace: s,
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 'networkFrom': networkFrom,
323 'networkTo': networkTo,
324 },
325 );
326 log(e.toString());
327 throw TradeNotCreatedException(description);
328 }
329 }
330
331 @override
332 Future<Trade> findTradeById({required String id}) async {
333 final headers = {
334 'Content-Type': 'application/json',
335 'Accept': 'application/json',
336 'Authorization': apiKey
337 };
338
339 final url = Uri.https(_baseUrl, '$_getTransactionPath/$id');
340 final response = await ProxyWrapper().get(clearnetUri: url, headers: headers);
341
342 if (response.statusCode != 200) {
343 throw Exception('LetsExchange fetch trade failed: ${response.body}');
344 }
345 final responseJSON = json.decode(response.body) as Map<String, dynamic>;
346
347 // Parsing 'from' currency
348 final fromCurrency = responseJSON['coin_from'] as String;
349 final fromNetwork = responseJSON['coin_from_network'] as String?;
350 final normalizedFromNetwork = _normalizeNetworkType(fromNetwork ?? '');
351 final fromTag = fromCurrency == normalizedFromNetwork ? null : normalizedFromNetwork;
352 final from = CryptoCurrency.safeParseCurrencyFromString(fromCurrency, tag: fromTag);
353
354 // Parsing 'to' currency
355 final toCurrency = responseJSON['coin_to'] as String;
356 final toNetwork = responseJSON['coin_to_network'] as String?;
357 final normalizedToNetwork = _normalizeNetworkType(toNetwork ?? '');
358 final toTag = toCurrency == normalizedToNetwork ? null : normalizedToNetwork;
359 final to = CryptoCurrency.safeParseCurrencyFromString(toCurrency, tag: toTag);
360
361 final payoutAddress = responseJSON['withdrawal'] as String;
362 final depositAddress = responseJSON['deposit'] as String;
363 final refundAddress = responseJSON['return'] as String;
364 final depositAmount = responseJSON['deposit_amount'] as String;
365 final receiveAmount = responseJSON['withdrawal_amount'] as String;
366 final status = responseJSON['status'] as String;
367
368 final extraId = responseJSON['deposit_extra_id'] as String?;
369
370 return Trade(
371 id: id,
372 from: from,
373 to: to,
374 provider: description,
375 inputAddress: depositAddress,
376 payoutAddress: payoutAddress,
377 refundAddress: refundAddress,
378 amount: depositAmount,
379 receiveAmount: receiveAmount,
380 state: TradeState.deserialize(raw: status),
381 isRefund: status == 'refund',
382 extraId: extraId,
383 );
384 }
385
386 Future<Map<String, dynamic>> _getInfo(Map<String, String> params, bool isFixedRateMode) async {
387 final headers = {
388 'Content-Type': 'application/json',
389 'Accept': 'application/json',
390 'Authorization': apiKey
391 };
392
393 try {
394 final uri = Uri.https(_baseUrl, isFixedRateMode ? _infoRevertPath : _infoPath);
395 final response = await ProxyWrapper().post(
396 clearnetUri: uri,
397 headers: headers,
398 body: json.encode(params),
399 );
400
401 if (response.statusCode != 200) {
402 throw Exception('LetsExchange fetch info failed: ${response.body}');
403 }
404 return json.decode(response.body) as Map<String, dynamic>;
405 } catch (e) {
406 throw Exception('LetsExchange failed to fetch info ${e.toString()}');
407 }
408 }
409
410 String? _getNetworkType(CryptoCurrency currency) {
411 if (currency.tag != null && currency.tag!.isNotEmpty) {
412 switch (currency.tag!) {
413 case 'TRX':
414 return 'TRC20';
415 case 'ETH':
416 return 'ERC20';
417 case 'BSC':
418 return 'BEP20';
419 case 'ARB':
420 return 'ARBITRUM';
421 default:
422 return currency.tag!;
423 }
424 }
425
426 return _normalizeTitleToNetwork(currency.title);
427 }
428
429 String _normalizeNetworkType(String network) {
430 return switch (network.toUpperCase()) {
431 'ERC20' => 'ETH',
432 'TRC20' => 'TRX',
433 'BEP20' => 'BSC',
434 'ARBITRUM' => 'ARB',
435 _ => network,
436 };
437 }
438
439 String _normalizeTitleToNetwork(String title) {
440 return switch (title.toUpperCase()) {
441 'ARB' => 'ARBITRUM',
442 _ => title,
443 };
444 }
445
446 String _normalizeBchAddress(String address) =>
447 address.startsWith('bitcoincash:') ? address.substring(12) : address;
448 }