dev
dart 355 lines 11.3 KB
Raw
1 import 'dart:convert';
2
3 import 'package:cake_wallet/exchange/exchange_provider_description.dart';
4 import 'package:cake_wallet/exchange/limits.dart';
5 import 'package:cake_wallet/exchange/provider/exchange_provider.dart';
6 import 'package:cake_wallet/exchange/trade.dart';
7 import 'package:cake_wallet/exchange/trade_request.dart';
8 import 'package:cake_wallet/exchange/trade_state.dart';
9 import 'package:cw_core/utils/proxy_wrapper.dart';
10 import 'package:cw_core/crypto_currency.dart';
11 import 'package:cw_core/utils/print_verbose.dart';
12 import 'package:cake_wallet/utils/exchange_provider_logger.dart';
13
14 class ThorChainExchangeProvider extends ExchangeProvider {
15 ThorChainExchangeProvider();
16
17 static final isRefundAddressSupported = [CryptoCurrency.eth];
18
19 static const _baseNodeURL = 'thornode.ninerealms.com';
20 static const _baseURL = 'midgard.ninerealms.com';
21 static const _quotePath = '/thorchain/quote/swap';
22 static const _txInfoPath = '/thorchain/tx/status/';
23 static const _affiliateName = 'cakewallet';
24 static const _affiliateBps = '175';
25 static const _nameLookUpPath = 'v2/thorname/lookup/';
26
27 @override
28 String get title => 'THORChain';
29
30 @override
31 bool get isAvailable => true;
32
33 @override
34 bool get isEnabled => true;
35
36 @override
37 bool get supportsFixedRate => false;
38
39 @override
40 bool get supportsMemoOrDestinationTag => false;
41
42 @override
43 ExchangeProviderDescription get description => ExchangeProviderDescription.thorChain;
44
45 @override
46 Future<bool> checkIsAvailable() async => true;
47
48 @override
49 Future<double> fetchRate(
50 {required CryptoCurrency from,
51 required CryptoCurrency to,
52 required double amount,
53 required bool isFixedRateMode,
54 required bool isReceiveAmount}) async {
55 try {
56 if (amount == 0) return 0.0;
57
58 final params = {
59 'from_asset': _normalizeCurrency(from),
60 'to_asset': _normalizeCurrency(to),
61 'amount': _doubleToThorChainString(amount),
62 'affiliate': _affiliateName,
63 'affiliate_bps': _affiliateBps
64 };
65
66 final responseJSON = await _getSwapQuote(params);
67
68 final expectedAmountOut = responseJSON['expected_amount_out'] as String? ?? '0.0';
69 final rate = _thorChainAmountToDouble(expectedAmountOut) / amount;
70
71 ExchangeProviderLogger.logSuccess(
72 provider: description,
73 function: 'fetchRate',
74 requestData: {
75 'from': from.title,
76 'to': to.title,
77 'amount': amount,
78 'isFixedRateMode': isFixedRateMode,
79 'isReceiveAmount': isReceiveAmount,
80 'params': params,
81 },
82 responseData: {
83 'expectedAmountOut': expectedAmountOut,
84 'rate': rate,
85 'responseJSON': responseJSON,
86 },
87 );
88
89 return rate;
90 } catch (e, s) {
91 ExchangeProviderLogger.logError(
92 provider: description,
93 function: 'fetchRate',
94 error: e,
95 stackTrace: s,
96 requestData: {
97 'from': from.title,
98 'to': to.title,
99 'amount': amount,
100 'isFixedRateMode': isFixedRateMode,
101 'isReceiveAmount': isReceiveAmount,
102 },
103 );
104 printV(e.toString());
105 return 0.0;
106 }
107 }
108
109 @override
110 Future<Limits?> fetchLimits(
111 {required CryptoCurrency from,
112 required CryptoCurrency to,
113 required bool isFixedRateMode}) async {
114 final params = {
115 'from_asset': _normalizeCurrency(from),
116 'to_asset': _normalizeCurrency(to),
117 'amount': _doubleToThorChainString(1),
118 'affiliate': _affiliateName,
119 'affiliate_bps': _affiliateBps
120 };
121
122 final responseJSON = await _getSwapQuote(params);
123 final minAmountIn = responseJSON['recommended_min_amount_in'] as String? ?? '0.0';
124
125 return Limits(min: _thorChainAmountToDouble(minAmountIn));
126 }
127
128 @override
129 Future<Trade> createTrade({
130 required TradeRequest request,
131 required bool isFixedRateMode,
132 required bool isSendAll,
133 }) async {
134 final formattedFromAmount = double.parse(request.fromAmount);
135
136 final params = {
137 'from_asset': _normalizeCurrency(request.fromCurrency),
138 'to_asset': _normalizeCurrency(request.toCurrency),
139 'amount': _doubleToThorChainString(formattedFromAmount),
140 'destination': _normalizeAddress(request.toAddress),
141 'affiliate': _affiliateName,
142 'affiliate_bps': _affiliateBps,
143 'refund_address': isRefundAddressSupported.contains(request.fromCurrency)
144 ? _normalizeAddress(request.refundAddress)
145 : '',
146 };
147
148 final responseJSON = await _getSwapQuote(params);
149
150 final inputAddress = responseJSON['inbound_address'] as String?;
151 final memo = responseJSON['memo'] as String?;
152 final directAmountOutResponse = responseJSON['expected_amount_out'] as String?;
153
154 String? receiveAmount;
155 if (directAmountOutResponse != null) {
156 receiveAmount = _thorChainAmountToDouble(directAmountOutResponse).toString();
157 }
158
159 ExchangeProviderLogger.logSuccess(
160 provider: description,
161 function: 'createTrade',
162 requestData: {
163 'from': request.fromCurrency.title,
164 'to': request.toCurrency.title,
165 'fromAmount': request.fromAmount,
166 'toAmount': request.toAmount,
167 'toAddress': request.toAddress,
168 'refundAddress': request.refundAddress,
169 'isFixedRateMode': isFixedRateMode,
170 'isSendAll': isSendAll,
171 'params': params,
172 },
173 responseData: {
174 'inputAddress': inputAddress,
175 'memo': memo,
176 'directAmountOutResponse': directAmountOutResponse,
177 'receiveAmount': receiveAmount,
178 'responseJSON': responseJSON,
179 },
180 );
181
182 return Trade(
183 id: '',
184 from: request.fromCurrency,
185 to: request.toCurrency,
186 provider: description,
187 inputAddress: inputAddress,
188 createdAt: DateTime.now(),
189 amount: request.fromAmount,
190 receiveAmount: receiveAmount ?? request.toAmount,
191 state: TradeState.notFound,
192 payoutAddress: request.toAddress,
193 memo: memo,
194 isSendAll: isSendAll,
195 );
196 }
197
198 @override
199 Future<Trade> findTradeById({required String id}) async {
200 if (id.isEmpty) throw Exception('Trade id is empty');
201 final formattedId = id.startsWith('0x') ? id.substring(2) : id;
202 final uri = Uri.https(_baseNodeURL, '$_txInfoPath$formattedId');
203 final response = await ProxyWrapper().get(clearnetUri: uri);
204
205 if (response.statusCode == 404) {
206 throw Exception('Trade not found for id: $formattedId');
207 } else if (response.statusCode != 200) {
208 throw Exception('Unexpected HTTP status: ${response.statusCode}');
209 }
210
211 final responseJSON = json.decode(response.body);
212 final Map<String, dynamic> stagesJson = responseJSON['stages'] as Map<String, dynamic>;
213
214 final inboundObservedStarted = stagesJson['inbound_observed']?['started'] as bool? ?? true;
215 if (!inboundObservedStarted) {
216 throw Exception('Trade has not started for id: $formattedId');
217 }
218
219 final currentState = _updateStateBasedOnStages(stagesJson) ?? TradeState.notFound;
220
221 final tx = responseJSON['tx'];
222 final String fromAddress = tx['from_address'] as String? ?? '';
223 final String toAddress = tx['to_address'] as String? ?? '';
224 final List<dynamic> coins = tx['coins'] as List<dynamic>;
225 final String? memo = tx['memo'] as String?;
226
227 final parts = memo?.split(':') ?? [];
228
229 final String toChain = parts.length > 1 ? parts[1].split('.')[0] : '';
230 final String toAsset = parts.length > 1 && parts[1].split('.').length > 1
231 ? parts[1].split('.')[1].split('-')[0]
232 : '';
233
234 final formattedToChain = CryptoCurrency.safeParseCurrencyFromString(toChain);
235 final toAssetWithChain =
236 CryptoCurrency.safeParseCurrencyFromString(toAsset, walletCurrency: formattedToChain);
237
238 final plannedOutTxs = responseJSON['planned_out_txs'] as List<dynamic>?;
239 final isRefund = plannedOutTxs?.any((tx) => tx['refund'] == true) ?? false;
240
241 return Trade(
242 id: id,
243 from: CryptoCurrency.fromString(tx['chain'] as String? ?? ''),
244 to: toAssetWithChain,
245 provider: description,
246 inputAddress: fromAddress,
247 payoutAddress: toAddress,
248 amount: coins.first['amount'] as String? ?? '0.0',
249 state: currentState,
250 memo: memo,
251 isRefund: isRefund,
252 );
253 }
254
255 static Future<Map<String, String>?>? lookupAddressByName(String name) async {
256 final uri = Uri.https(_baseURL, '$_nameLookUpPath$name');
257 try {
258 final response = await ProxyWrapper().get(clearnetUri: uri);
259
260 if (response.statusCode != 200) {
261 return null;
262 }
263
264 final body = json.decode(response.body) as Map<String, dynamic>;
265 final entries = body['entries'] as List<dynamic>?;
266
267 if (entries == null || entries.isEmpty) {
268 return null;
269 }
270
271 Map<String, String> chainToAddressMap = {};
272
273 for (final entry in entries) {
274 final chain = entry['chain'] as String;
275 final address = entry['address'] as String;
276 chainToAddressMap[chain] = address;
277 }
278
279 return chainToAddressMap;
280 } catch (e) {
281 printV(e.toString());
282 return null;
283 }
284 }
285
286 Future<Map<String, dynamic>> _getSwapQuote(Map<String, String> params) async {
287 Uri uri = Uri.https(_baseNodeURL, _quotePath, params);
288
289 final response = await ProxyWrapper().get(clearnetUri: uri);
290
291 if (response.statusCode != 200) {
292 ExchangeProviderLogger.logError(
293 provider: description,
294 function: '_getSwapQuote',
295 error: Exception('Unexpected HTTP status: ${response.statusCode}'),
296 stackTrace: StackTrace.current,
297 requestData: {
298 'params': params,
299 'url': uri.toString(),
300 },
301 );
302 throw Exception('Unexpected HTTP status: ${response.statusCode}');
303 }
304
305 if (response.body.contains('error')) {
306 ExchangeProviderLogger.logError(
307 provider: description,
308 function: '_getSwapQuote',
309 error: Exception('Unexpected response: ${response.body}'),
310 stackTrace: StackTrace.current,
311 requestData: {
312 'params': params,
313 'url': uri.toString(),
314 },
315 );
316 throw Exception('Unexpected response: ${response.body}');
317 }
318
319 return json.decode(response.body) as Map<String, dynamic>;
320 }
321
322 String _normalizeCurrency(CryptoCurrency currency) {
323 final networkTitle = currency.tag == 'ETH' ? 'ETH' : currency.tag ?? currency.title;
324 return '$networkTitle.${currency.title}';
325 }
326
327 String _doubleToThorChainString(double amount) => (amount * 1e8).toInt().toString();
328
329 double _thorChainAmountToDouble(String amount) => double.parse(amount) / 1e8;
330
331 TradeState? _updateStateBasedOnStages(Map<String, dynamic> stages) {
332 TradeState? currentState;
333
334 if (stages['inbound_observed']['completed'] as bool? ?? false) {
335 currentState = TradeState.confirmation;
336 }
337 if (stages['inbound_confirmation_counted']['completed'] as bool? ?? false) {
338 currentState = TradeState.confirmed;
339 }
340 if (stages['inbound_finalised']['completed'] as bool? ?? false) {
341 currentState = TradeState.processing;
342 }
343 if (stages['swap_finalised']['completed'] as bool? ?? false) {
344 currentState = TradeState.traded;
345 }
346 if (stages['outbound_signed']['completed'] as bool? ?? false) {
347 currentState = TradeState.success;
348 }
349
350 return currentState;
351 }
352
353 String _normalizeAddress(String address) =>
354 address.startsWith('bitcoincash:') ? address.replaceFirst('bitcoincash:', '') : address;
355 }