dev
dart 450 lines 15.4 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:cake_wallet/utils/exchange_provider_logger.dart';
15
16 class StealthExExchangeProvider extends ExchangeProvider {
17 StealthExExchangeProvider();
18
19 static final apiKey = secrets.stealthExBearerToken;
20 static final _additionalFeePercent = double.tryParse(secrets.stealthExAdditionalFeePercent);
21 static const _baseUrl = 'https://api.stealthex.io';
22 static const _rangePath = '/v4/rates/range';
23 static const _amountPath = '/v4/rates/estimated-amount';
24 static const _exchangesPath = '/v4/exchanges';
25
26 @override
27 String get title => 'StealthEX';
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.stealthEx;
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}) async {
49 final curFrom = isFixedRateMode ? to : from;
50 final curTo = isFixedRateMode ? from : to;
51
52 final headers = {'Authorization': apiKey, 'Content-Type': 'application/json'};
53 final body = {
54 'route': {
55 'from': {'symbol': _getName(curFrom), 'network': _getNetwork(curFrom)},
56 'to': {'symbol': _getName(curTo), 'network': _getNetwork(curTo)}
57 },
58 'estimation': isFixedRateMode ? 'reversed' : 'direct',
59 'rate': isFixedRateMode ? 'fixed' : 'floating',
60 'additional_fee_percent': _additionalFeePercent,
61 };
62
63 try {
64 final response = await ProxyWrapper().post(
65 clearnetUri: Uri.parse(_baseUrl + _rangePath),
66 headers: headers,
67 body: json.encode(body),
68 );
69
70 if (response.statusCode != 200) {
71 throw Exception('StealthEx fetch limits failed: ${response.body}');
72 }
73 final responseJSON = json.decode(response.body) as Map<String, dynamic>;
74 final min = _toDouble(responseJSON['min_amount']);
75 final max = _toDouble(responseJSON['max_amount']);
76 return Limits(min: min, max: max);
77 } catch (e) {
78 log(e.toString());
79 throw Exception('StealthEx failed to fetch limits');
80 }
81 }
82
83 @override
84 Future<double> fetchRate(
85 {required CryptoCurrency from,
86 required CryptoCurrency to,
87 required double amount,
88 required bool isFixedRateMode,
89 required bool isReceiveAmount}) async {
90 try {
91 final response = await getEstimatedExchangeAmount(
92 from: from,
93 to: to,
94 amount: amount,
95 isFixedRateMode: isFixedRateMode,
96 );
97 final estimatedAmount = response['estimated_amount'] as double? ?? 0.0;
98
99 if (estimatedAmount <= 0.0) {
100 ExchangeProviderLogger.logError(
101 provider: description,
102 function: 'fetchRate',
103 error: Exception('Invalid estimated amount: $estimatedAmount'),
104 stackTrace: StackTrace.current,
105 requestData: {
106 'from': from.title,
107 'to': to.title,
108 'amount': amount,
109 'isFixedRateMode': isFixedRateMode,
110 'isReceiveAmount': isReceiveAmount,
111 },
112 );
113 return 0.0;
114 }
115
116 final rate = isFixedRateMode ? amount / estimatedAmount : estimatedAmount / amount;
117
118 ExchangeProviderLogger.logSuccess(
119 provider: description,
120 function: 'fetchRate',
121 requestData: {
122 'from': from.title,
123 'to': to.title,
124 'amount': amount,
125 'isFixedRateMode': isFixedRateMode,
126 'isReceiveAmount': isReceiveAmount,
127 },
128 responseData: {
129 'estimatedAmount': estimatedAmount,
130 'rate': rate,
131 'response': response,
132 },
133 );
134
135 return rate;
136 } catch (e, s) {
137 ExchangeProviderLogger.logError(
138 provider: description,
139 function: 'fetchRate',
140 error: e,
141 stackTrace: s,
142 requestData: {
143 'from': from.title,
144 'to': to.title,
145 'amount': amount,
146 'isFixedRateMode': isFixedRateMode,
147 'isReceiveAmount': isReceiveAmount,
148 },
149 );
150 return 0.0;
151 }
152 }
153
154 @override
155 Future<Trade> createTrade(
156 {required TradeRequest request,
157 required bool isFixedRateMode,
158 required bool isSendAll}) async {
159 String? rateId;
160 String? validUntil;
161
162 try {
163 if (isFixedRateMode) {
164 final response = await getEstimatedExchangeAmount(
165 from: request.fromCurrency,
166 to: request.toCurrency,
167 amount: double.parse(request.toAmount),
168 isFixedRateMode: isFixedRateMode);
169 rateId = response['rate_id'] as String?;
170 validUntil = response['valid_until'] as String?;
171 if (rateId == null) throw TradeNotCreatedException(description);
172 }
173
174 final headers = {'Authorization': apiKey, 'Content-Type': 'application/json'};
175 final body = {
176 'route': {
177 'from': {
178 'symbol': _getName(request.fromCurrency),
179 'network': _getNetwork(request.fromCurrency)
180 },
181 'to': {'symbol': _getName(request.toCurrency), 'network': _getNetwork(request.toCurrency)}
182 },
183 'estimation': isFixedRateMode ? 'reversed' : 'direct',
184 'rate': isFixedRateMode ? 'fixed' : 'floating',
185 if (isFixedRateMode) 'rate_id': rateId,
186 'amount':
187 isFixedRateMode ? double.parse(request.toAmount) : double.parse(request.fromAmount),
188 'address': _normalizeAddress(request.toAddress),
189 if (request.toAddressExtraId.isNotEmpty) 'extra_id': request.toAddressExtraId,
190 'refund_address': _normalizeAddress(request.refundAddress),
191 'additional_fee_percent': _additionalFeePercent,
192 };
193
194 final response = await ProxyWrapper().post(
195 clearnetUri: Uri.parse(_baseUrl + _exchangesPath),
196 headers: headers,
197 body: json.encode(body),
198 );
199
200 if (response.statusCode != 201) {
201 ExchangeProviderLogger.logError(
202 provider: description,
203 function: 'createTrade',
204 error: Exception('StealthEx 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 'body': body,
216 'rateId': rateId,
217 },
218 );
219 throw Exception('StealthEx create trade failed: ${response.body}');
220 }
221 final responseJSON = json.decode(response.body) as Map<String, dynamic>;
222 final deposit = responseJSON['deposit'] as Map<String, dynamic>;
223 final withdrawal = responseJSON['withdrawal'] as Map<String, dynamic>;
224
225 final id = responseJSON['id'] as String;
226 final from = deposit['symbol'] as String;
227 final to = withdrawal['symbol'] as String;
228 final payoutAddress = withdrawal['address'] as String;
229 final depositAddress = deposit['address'] as String;
230 final refundAddress = responseJSON['refund_address'] as String;
231 final depositAmount = _toDouble(deposit['amount']);
232 final receiveAmount = _toDouble(withdrawal['amount']);
233 final status = responseJSON['status'] as String;
234 final createdAtString = responseJSON['created_at'] as String;
235 final extraId = deposit['extra_id'] as String?;
236
237 final createdAt = DateTime.parse(createdAtString).toLocal();
238 final expiredAt = validUntil != null
239 ? DateTime.parse(validUntil).toLocal()
240 : DateTime.now().add(Duration(minutes: 5));
241
242 CryptoCurrency fromCurrency;
243 if (request.fromCurrency.tag != null && request.fromCurrency.title.toLowerCase() == 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.toLowerCase() == 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 'body': body,
269 'rateId': rateId,
270 },
271 responseData: {
272 'id': id,
273 'from': from,
274 'to': to,
275 'depositAddress': depositAddress,
276 'payoutAddress': payoutAddress,
277 'refundAddress': refundAddress,
278 'depositAmount': depositAmount,
279 'receiveAmount': receiveAmount,
280 'status': status,
281 'createdAt': createdAtString,
282 'extraId': extraId,
283 'statusCode': response.statusCode,
284 'responseJSON': responseJSON,
285 },
286 );
287
288 return Trade(
289 id: id,
290 from: fromCurrency,
291 to: toCurrency,
292 provider: description,
293 inputAddress: depositAddress,
294 payoutAddress: payoutAddress,
295 refundAddress: refundAddress,
296 amount: depositAmount.toString(),
297 receiveAmount: receiveAmount.toString(),
298 state: TradeState.deserialize(raw: status),
299 createdAt: createdAt,
300 expiredAt: expiredAt,
301 extraId: extraId,
302 isSendAll: isSendAll,
303 toAddressExtraId: request.toAddressExtraId,
304 );
305 } catch (e, s) {
306 ExchangeProviderLogger.logError(
307 provider: description,
308 function: 'createTrade',
309 error: e,
310 stackTrace: s,
311 requestData: {
312 'from': request.fromCurrency.title,
313 'to': request.toCurrency.title,
314 'fromAmount': request.fromAmount,
315 'toAmount': request.toAmount,
316 'toAddress': request.toAddress,
317 'refundAddress': request.refundAddress,
318 'isFixedRateMode': isFixedRateMode,
319 'isSendAll': isSendAll,
320 },
321 );
322 log(e.toString());
323 throw TradeNotCreatedException(description);
324 }
325 }
326
327 @override
328 Future<Trade> findTradeById({required String id}) async {
329 final headers = {'Authorization': apiKey, 'Content-Type': 'application/json'};
330
331 final uri = Uri.parse('$_baseUrl$_exchangesPath/$id');
332 final response = await ProxyWrapper().get(clearnetUri: uri, headers: headers);
333
334 if (response.statusCode != 200) {
335 throw Exception('StealthEx fetch trade failed: ${response.body}');
336 }
337 final responseJSON = json.decode(response.body) as Map<String, dynamic>;
338 final deposit = responseJSON['deposit'] as Map<String, dynamic>;
339 final withdrawal = responseJSON['withdrawal'] as Map<String, dynamic>;
340
341 final respId = responseJSON['id'] as String;
342
343 // Parsing 'from' currency with network tag
344 final fromCurrency = deposit['symbol'] as String;
345 final fromNetwork = deposit['network'] as String?;
346 final fromTag = fromNetwork == 'mainnet' ? null : fromNetwork;
347 final from = CryptoCurrency.safeParseCurrencyFromString(fromCurrency, tag: fromTag);
348
349 // Parsing 'to' currency with network tag
350 final toCurrency = withdrawal['symbol'] as String;
351 final toNetwork = withdrawal['network'] as String?;
352 final toTag = toNetwork == 'mainnet' ? null : toNetwork;
353 final to = CryptoCurrency.safeParseCurrencyFromString(toCurrency, tag: toTag);
354
355 final payoutAddress = withdrawal['address'] as String;
356 final depositAddress = deposit['address'] as String;
357 final refundAddress = responseJSON['refund_address'] as String;
358 final depositAmount = _toDouble(deposit['amount']);
359 final receiveAmount = _toDouble(withdrawal['amount']);
360 final status = responseJSON['status'] as String;
361 final createdAtString = responseJSON['created_at'] as String;
362 final createdAt = DateTime.parse(createdAtString).toLocal();
363 final extraId = deposit['extra_id'] as String?;
364
365 return Trade(
366 id: respId,
367 from: from,
368 to: to,
369 provider: description,
370 inputAddress: depositAddress,
371 payoutAddress: payoutAddress,
372 refundAddress: refundAddress,
373 amount: depositAmount.toString(),
374 receiveAmount: receiveAmount.toString(),
375 state: TradeState.deserialize(raw: status),
376 createdAt: createdAt,
377 isRefund: status == 'refunded',
378 extraId: extraId,
379 );
380 }
381
382 Future<Map<String, dynamic>> getEstimatedExchangeAmount(
383 {required CryptoCurrency from,
384 required CryptoCurrency to,
385 required double amount,
386 required bool isFixedRateMode}) async {
387 final headers = {'Authorization': apiKey, 'Content-Type': 'application/json'};
388
389 final body = {
390 'route': {
391 'from': {'symbol': _getName(from), 'network': _getNetwork(from)},
392 'to': {'symbol': _getName(to), 'network': _getNetwork(to)}
393 },
394 'estimation': isFixedRateMode ? 'reversed' : 'direct',
395 'rate': isFixedRateMode ? 'fixed' : 'floating',
396 'amount': amount,
397 'additional_fee_percent': _additionalFeePercent,
398 };
399
400 try {
401 final response = await ProxyWrapper().post(
402 clearnetUri: Uri.parse(_baseUrl + _amountPath),
403 headers: headers,
404 body: json.encode(body),
405 );
406
407 if (response.statusCode != 200) return {};
408 final responseJSON = json.decode(response.body) as Map<String, dynamic>;
409 final rate = responseJSON['rate'] as Map<String, dynamic>?;
410 return {
411 'estimated_amount': responseJSON['estimated_amount'] as double?,
412 if (rate != null) 'valid_until': rate['valid_until'] as String?,
413 if (rate != null) 'rate_id': rate['id'] as String?
414 };
415 } catch (e) {
416 log(e.toString());
417 return {};
418 }
419 }
420
421 static double? _toDouble(dynamic value) {
422 if (value is int) {
423 return value.toDouble();
424 } else if (value is double) {
425 return value;
426 } else if (value is String) {
427 return double.tryParse(value);
428 }
429 return null;
430 }
431
432 String _getName(CryptoCurrency currency) {
433 if (currency == CryptoCurrency.usdcEPoly) return 'usdce';
434 return currency.title.toLowerCase();
435 }
436
437 String _getNetwork(CryptoCurrency currency) {
438 if (currency == CryptoCurrency.arb || currency.tag == 'ARB') return 'arbitrum';
439 if (currency.tag == null) return 'mainnet';
440
441 if (currency == CryptoCurrency.maticpoly) return 'mainnet';
442
443 if (currency.tag == 'POLY') return 'matic';
444
445 return currency.tag!.toLowerCase();
446 }
447
448 String _normalizeAddress(String address) =>
449 address.startsWith('bitcoincash:') ? address.replaceFirst('bitcoincash:', '') : address;
450 }