dev
dart 372 lines 12.9 KB
Raw
1 import 'dart:convert';
2 import 'dart:io';
3
4 import 'package:cake_wallet/.secrets.g.dart' as secrets;
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/store/settings_store.dart';
13 import 'package:cake_wallet/utils/distribution_info.dart';
14 import 'package:cw_core/utils/proxy_wrapper.dart';
15 import 'package:cake_wallet/wallet_type_utils.dart';
16 import 'package:cw_core/crypto_currency.dart';
17 import 'package:cw_core/utils/print_verbose.dart';
18 import 'package:cake_wallet/utils/exchange_provider_logger.dart';
19
20 class ChangeNowExchangeProvider extends ExchangeProvider {
21 ChangeNowExchangeProvider({required SettingsStore settingsStore})
22 : _settingsStore = settingsStore,
23 _lastUsedRateId = '';
24
25 static final apiKey =
26 isMoneroOnly ? secrets.changeNowMoneroApiKey : secrets.changeNowCakeWalletApiKey;
27 static const apiAuthority = 'api.changenow.io';
28 static const createTradePath = '/v2/exchange';
29 static const findTradeByIdPath = '/v2/exchange/by-id';
30 static const estimatedAmountPath = '/v2/exchange/estimated-amount';
31 static const rangePath = '/v2/exchange/range';
32 static const apiHeaderKey = 'x-changenow-api-key';
33
34 final SettingsStore _settingsStore;
35 String _lastUsedRateId;
36
37 @override
38 String get title => 'ChangeNOW';
39
40 @override
41 bool get isAvailable => true;
42
43 @override
44 bool get isEnabled => true;
45
46 @override
47 bool get supportsFixedRate => true;
48
49 @override
50 ExchangeProviderDescription get description => ExchangeProviderDescription.changeNow;
51
52 @override
53 Future<bool> checkIsAvailable() async => true;
54
55 @override
56 Future<Limits?> fetchLimits(
57 {required CryptoCurrency from,
58 required CryptoCurrency to,
59 required bool isFixedRateMode}) async {
60 final headers = {apiHeaderKey: apiKey};
61 final params = <String, String>{
62 'fromCurrency': _normalizeCurrency(from),
63 'toCurrency': _normalizeCurrency(to),
64 'fromNetwork': _networkFor(from),
65 'toNetwork': _networkFor(to),
66 'flow': _getFlow(isFixedRateMode)
67 };
68 final uri = Uri.https(apiAuthority, rangePath, params);
69 final response = await ProxyWrapper().get(clearnetUri: uri, headers: headers);
70
71 if (response.statusCode == 400) {
72 final responseJSON = json.decode(response.body) as Map<String, dynamic>;
73 final error = responseJSON['error'] as String;
74 final message = responseJSON['message'] as String;
75 throw Exception('${error}\n$message');
76 }
77
78 if (response.statusCode != 200)
79 throw Exception('Unexpected http status: ${response.statusCode}');
80
81 final responseJSON = json.decode(response.body) as Map<String, dynamic>;
82 final min = double.tryParse(responseJSON['minAmount']?.toString() ?? '');
83 final max = double.tryParse(responseJSON['maxAmount']?.toString() ?? '');
84 if (max == 0) return null;
85 return Limits(min: min, max: max);
86 }
87
88 @override
89 Future<double> fetchRate(
90 {required CryptoCurrency from,
91 required CryptoCurrency to,
92 required double amount,
93 required bool isFixedRateMode,
94 required bool isReceiveAmount}) async {
95 try {
96 if (amount == 0) return 0.0;
97
98 final headers = {apiHeaderKey: apiKey};
99 final isReverse = isReceiveAmount;
100 final type = isReverse ? 'reverse' : 'direct';
101 final params = <String, String>{
102 'fromCurrency': _normalizeCurrency(from),
103 'toCurrency': _normalizeCurrency(to),
104 'fromNetwork': _networkFor(from),
105 'toNetwork': _networkFor(to),
106 'type': type,
107 'flow': _getFlow(isFixedRateMode)
108 };
109
110 if (isReverse)
111 params['toAmount'] = amount.toString();
112 else
113 params['fromAmount'] = amount.toString();
114
115 final uri = Uri.https(apiAuthority, estimatedAmountPath, params);
116 final response = await ProxyWrapper().get(clearnetUri: uri, headers: headers);
117
118 final responseJSON = json.decode(response.body) as Map<String, dynamic>;
119 final fromAmount = double.tryParse(responseJSON['fromAmount']?.toString() ?? '') ?? 0.0;
120 final toAmount = double.tryParse(responseJSON['toAmount']?.toString() ?? '') ?? 0.0;
121 if (fromAmount <= 0 || toAmount <= 0) return 0.0;
122 final rateId = responseJSON['rateId'] as String? ?? '';
123
124 if (rateId.isNotEmpty) _lastUsedRateId = rateId;
125
126 final rate = isReverse ? (amount / fromAmount) : (toAmount / amount);
127
128 ExchangeProviderLogger.logSuccess(
129 provider: description,
130 function: 'fetchRate',
131 requestData: {
132 'from': from.title,
133 'to': to.title,
134 'amount': amount,
135 'isFixedRateMode': isFixedRateMode,
136 'isReceiveAmount': isReceiveAmount,
137 'type': type,
138 'flow': _getFlow(isFixedRateMode),
139 },
140 responseData: {
141 'fromAmount': fromAmount,
142 'toAmount': toAmount,
143 'rateId': rateId,
144 'rate': rate,
145 },
146 );
147
148 return rate;
149 } catch (e, s) {
150 ExchangeProviderLogger.logError(
151 provider: description,
152 function: 'fetchRate',
153 error: e,
154 stackTrace: s,
155 requestData: {
156 'from': from.title,
157 'to': to.title,
158 'amount': amount,
159 'isFixedRateMode': isFixedRateMode,
160 'isReceiveAmount': isReceiveAmount,
161 },
162 );
163 printV(e.toString());
164 return 0.0;
165 }
166 }
167
168 @override
169 Future<Trade> createTrade({
170 required TradeRequest request,
171 required bool isFixedRateMode,
172 required bool isSendAll,
173 }) async {
174 final distributionPath = await DistributionInfo.instance.getDistributionPath();
175 final formattedAppVersion = int.tryParse(_settingsStore.appVersion.replaceAll('.', '')) ?? 0;
176 final payload = {
177 'app': isMoneroOnly ? 'monerocom' : 'cakewallet',
178 'device': Platform.operatingSystem,
179 'distribution': distributionPath,
180 'version': formattedAppVersion
181 };
182 final headers = {apiHeaderKey: apiKey, 'Content-Type': 'application/json'};
183 final type = isFixedRateMode ? 'reverse' : 'direct';
184 final body = <String, dynamic>{
185 'fromCurrency': _normalizeCurrency(request.fromCurrency),
186 'toCurrency': _normalizeCurrency(request.toCurrency),
187 'fromNetwork': _networkFor(request.fromCurrency),
188 'toNetwork': _networkFor(request.toCurrency),
189 if (!isFixedRateMode) 'fromAmount': request.fromAmount,
190 if (isFixedRateMode) 'toAmount': request.toAmount,
191 'address': request.toAddress,
192 if (request.toAddressExtraId.isNotEmpty) 'extraId': request.toAddressExtraId,
193 'flow': _getFlow(isFixedRateMode),
194 'type': type,
195 'refundAddress': request.refundAddress,
196 'payload': payload,
197 };
198
199 if (isFixedRateMode) {
200 // since we schedule to calculate the rate every 5 seconds we need to ensure that
201 // we have the latest rate id with the given inputs before creating the trade
202 await fetchRate(
203 from: request.fromCurrency,
204 to: request.toCurrency,
205 amount: double.tryParse(request.toAmount) ?? 0,
206 isFixedRateMode: true,
207 isReceiveAmount: true,
208 );
209 body['rateId'] = _lastUsedRateId;
210 }
211
212 final uri = Uri.https(apiAuthority, createTradePath);
213 final response = await ProxyWrapper().post(
214 clearnetUri: uri,
215 headers: headers,
216 body: json.encode(body),
217 );
218
219 if (response.statusCode == 400) {
220 final responseJSON = json.decode(response.body) as Map<String, dynamic>;
221 final error = responseJSON['error'] as String;
222 final message = responseJSON['message'] as String;
223 throw Exception('${error}\n$message');
224 }
225
226 if (response.statusCode != 200)
227 throw Exception('Unexpected http status: ${response.statusCode}');
228
229 final responseJSON = json.decode(response.body) as Map<String, dynamic>;
230 final id = responseJSON['id'] as String;
231 final inputAddress = responseJSON['payinAddress'] as String;
232 final refundAddress = responseJSON['refundAddress'] as String;
233 final extraId = responseJSON['payinExtraId'] as String?;
234 final payoutAddress = responseJSON['payoutAddress'] as String;
235 final fromAmount = responseJSON['fromAmount']?.toString();
236 final toAmount = responseJSON['toAmount']?.toString();
237
238 return Trade(
239 id: id,
240 from: request.fromCurrency,
241 to: request.toCurrency,
242 provider: description,
243 inputAddress: inputAddress,
244 refundAddress: refundAddress,
245 extraId: extraId,
246 createdAt: DateTime.now(),
247 amount: fromAmount ?? request.fromAmount,
248 receiveAmount: toAmount ?? request.toAmount,
249 state: TradeState.created,
250 payoutAddress: payoutAddress,
251 isSendAll: isSendAll,
252 toAddressExtraId: request.toAddressExtraId,
253 );
254 }
255
256 @override
257 Future<Trade> findTradeById({required String id}) async {
258 final headers = {apiHeaderKey: apiKey};
259 final params = <String, String>{'id': id};
260 final uri = Uri.https(apiAuthority, findTradeByIdPath, params);
261 final response = await ProxyWrapper().get(clearnetUri: uri, headers: headers);
262
263 if (response.statusCode == 404) throw TradeNotFoundException(id, provider: description);
264
265 if (response.statusCode == 400) {
266 final responseJSON = json.decode(response.body) as Map<String, dynamic>;
267 final error = responseJSON['message'] as String;
268
269 throw TradeNotFoundException(id, provider: description, description: error);
270 }
271
272 if (response.statusCode != 200)
273 throw Exception('Unexpected http status: ${response.statusCode}');
274
275 final responseJSON = json.decode(response.body) as Map<String, dynamic>;
276
277 // Parsing 'from' currency
278 final fromCurrency = responseJSON['fromCurrency'] as String;
279 final fromNetwork = responseJSON['fromNetwork'] as String?;
280 final _normalizedFromNetwork = _normalizeNetworkType(fromNetwork ?? '');
281 final fromTag = fromCurrency.toUpperCase() == _normalizedFromNetwork.toUpperCase()
282 ? null
283 : _normalizedFromNetwork;
284 final from = CryptoCurrency.safeParseCurrencyFromString(fromCurrency, tag: fromTag);
285
286 // Parsing 'to' currency
287 final toCurrency = responseJSON['toCurrency'] as String;
288 final toNetwork = responseJSON['toNetwork'] as String?;
289 final _normalizedToNetwork = _normalizeNetworkType(toNetwork ?? '');
290 final toTag = toCurrency.toUpperCase() == _normalizedToNetwork.toUpperCase()
291 ? null
292 : _normalizedToNetwork;
293 final to = CryptoCurrency.safeParseCurrencyFromString(toCurrency, tag: toTag);
294
295 final inputAddress = responseJSON['payinAddress'] as String;
296 final expectedSendAmount = responseJSON['expectedAmountFrom'].toString();
297 final status = responseJSON['status'] as String;
298 final state = TradeState.deserialize(raw: status);
299 final extraId = responseJSON['payinExtraId'] as String?;
300 final outputTransaction = responseJSON['payoutHash'] as String?;
301 final expiredAtRaw = responseJSON['validUntil'] as String?;
302 final payoutAddress = responseJSON['payoutAddress'] as String;
303 final expiredAt = DateTime.tryParse(expiredAtRaw ?? '')?.toLocal();
304
305 return Trade(
306 id: id,
307 from: from,
308 to: to,
309 provider: description,
310 inputAddress: inputAddress,
311 amount: expectedSendAmount,
312 state: state,
313 extraId: extraId,
314 expiredAt: expiredAt,
315 outputTransaction: outputTransaction,
316 payoutAddress: payoutAddress,
317 );
318 }
319
320 String _getFlow(bool isFixedRate) => isFixedRate ? 'fixed-rate' : 'standard';
321
322 String _networkFor(CryptoCurrency currency) {
323 switch (currency) {
324 case CryptoCurrency.usdt:
325 return 'btc';
326 case CryptoCurrency.arb:
327 return 'arbitrum';
328 case CryptoCurrency.nano:
329 return 'nano';
330 default:
331 return currency.tag != null ? _normalizeTag(currency.tag!) : currency.title.toLowerCase();
332 }
333 }
334
335 String _normalizeCurrency(CryptoCurrency currency) {
336 if (currency.title == "USDC" && currency.tag == "POLY") {
337 throw "Only Bridged USDC (USDC.e) is allowed in ChangeNow";
338 }
339 switch (currency) {
340 case CryptoCurrency.zec:
341 return 'zec';
342 case CryptoCurrency.maticpoly:
343 return 'matic';
344 default:
345 return currency.title.toLowerCase();
346 }
347 }
348
349 String _normalizeTag(String tag) {
350 switch (tag) {
351 case 'POL':
352 return 'matic';
353 case 'LN':
354 return 'lightning';
355 case 'AVAXC':
356 return 'cchain';
357 case 'ARB':
358 return 'arbitrum';
359 default:
360 return tag.toLowerCase();
361 }
362 }
363
364 String _normalizeNetworkType(String network) {
365 return switch (network.toUpperCase()) {
366 'POLY' => 'MATIC',
367 'AVAXC' => 'CCHAIN',
368 'ARBITRUM' => 'ARB',
369 _ => network,
370 };
371 }
372 }