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:cake_wallet/exchange/utils/currency_pairs_utils.dart';
10
+import 'package:cw_core/crypto_currency.dart';
11
+import 'package:hive/hive.dart';
12
+import 'package:http/http.dart' as http;
13
+
14
+class ThorChainExchangeProvider extends ExchangeProvider {
15
+ ThorChainExchangeProvider({required this.tradesStore})
16
+ : super(pairList: supportedPairs(_notSupported));
17
+
18
+ static final List<CryptoCurrency> _notSupported = [
19
+ ...(CryptoCurrency.all
20
+ .where((element) => ![
21
+ CryptoCurrency.btc,
22
+ CryptoCurrency.eth,
23
+ CryptoCurrency.ltc,
24
+ CryptoCurrency.bch,
25
+ CryptoCurrency.aave,
26
+ CryptoCurrency.dai,
27
+ CryptoCurrency.gusd,
28
+ CryptoCurrency.usdc,
29
+ CryptoCurrency.usdterc20,
30
+ CryptoCurrency.wbtc,
31
+ ].contains(element))
32
+ .toList())
33
+ ];
34
+
35
+ static final isRefundAddressSupported = [CryptoCurrency.eth];
36
+
37
+ static const _baseURL = 'thornode.ninerealms.com';
38
+ static const _quotePath = '/thorchain/quote/swap';
39
+ static const _txInfoPath = '/thorchain/tx/status/';
40
+ static const _affiliateName = 'cakewallet';
41
+ static const _affiliateBps = '175';
42
+
43
+ final Box<Trade> tradesStore;
44
+
45
+ @override
46
+ String get title => 'THORChain';
47
+
48
+ @override
49
+ bool get isAvailable => true;
50
+
51
+ @override
52
+ bool get isEnabled => true;
53
+
54
+ @override
55
+ bool get supportsFixedRate => false;
56
+
57
+ @override
58
+ ExchangeProviderDescription get description => ExchangeProviderDescription.thorChain;
59
+
60
+ @override
61
+ Future<bool> checkIsAvailable() async => true;
62
+
63
+ @override
64
+ Future<double> fetchRate(
65
+ {required CryptoCurrency from,
66
+ required CryptoCurrency to,
67
+ required double amount,
68
+ required bool isFixedRateMode,
69
+ required bool isReceiveAmount}) async {
70
+ try {
71
+ if (amount == 0) return 0.0;
72
+
73
+ final params = {
74
+ 'from_asset': _normalizeCurrency(from),
75
+ 'to_asset': _normalizeCurrency(to),
76
+ 'amount': _doubleToThorChainString(amount),
77
+ 'affiliate': _affiliateName,
78
+ 'affiliate_bps': _affiliateBps
79
+ };
80
+
81
+ final responseJSON = await _getSwapQuote(params);
82
+
83
+ final expectedAmountOut = responseJSON['expected_amount_out'] as String? ?? '0.0';
84
+
85
+ return _thorChainAmountToDouble(expectedAmountOut) / amount;
86
+ } catch (e) {
87
+ print(e.toString());
88
+ return 0.0;
89
+ }
90
+ }
91
+
92
+ @override
93
+ Future<Limits> fetchLimits(
94
+ {required CryptoCurrency from,
95
+ required CryptoCurrency to,
96
+ required bool isFixedRateMode}) async {
97
+ final params = {
98
+ 'from_asset': _normalizeCurrency(from),
99
+ 'to_asset': _normalizeCurrency(to),
100
+ 'amount': _doubleToThorChainString(1),
101
+ 'affiliate': _affiliateName,
102
+ 'affiliate_bps': _affiliateBps
103
+ };
104
+
105
+ final responseJSON = await _getSwapQuote(params);
106
+ final minAmountIn = responseJSON['recommended_min_amount_in'] as String? ?? '0.0';
107
+
108
+ return Limits(min: _thorChainAmountToDouble(minAmountIn));
109
+ }
110
+
111
+ @override
112
+ Future<Trade> createTrade({required TradeRequest request, required bool isFixedRateMode}) async {
113
+ String formattedToAddress = request.toAddress.startsWith('bitcoincash:')
114
+ ? request.toAddress.replaceFirst('bitcoincash:', '')
115
+ : request.toAddress;
116
+
117
+ final formattedFromAmount = double.parse(request.fromAmount);
118
+
119
+ final params = {
120
+ 'from_asset': _normalizeCurrency(request.fromCurrency),
121
+ 'to_asset': _normalizeCurrency(request.toCurrency),
122
+ 'amount': _doubleToThorChainString(formattedFromAmount),
123
+ 'destination': formattedToAddress,
124
+ 'affiliate': _affiliateName,
125
+ 'affiliate_bps': _affiliateBps,
126
+ 'refund_address':
127
+ isRefundAddressSupported.contains(request.fromCurrency) ? request.refundAddress : '',
128
+ };
129
+
130
+ final responseJSON = await _getSwapQuote(params);
131
+
132
+ final inputAddress = responseJSON['inbound_address'] as String?;
133
+ final memo = responseJSON['memo'] as String?;
134
+
135
+ return Trade(
136
+ id: '',
137
+ from: request.fromCurrency,
138
+ to: request.toCurrency,
139
+ provider: description,
140
+ inputAddress: inputAddress,
141
+ createdAt: DateTime.now(),
142
+ amount: request.fromAmount,
143
+ state: TradeState.notFound,
144
+ payoutAddress: request.toAddress,
145
+ memo: memo);
146
+ }
147
+
148
+ @override
149
+ Future<Trade> findTradeById({required String id}) async {
150
+ if (id.isEmpty) throw Exception('Trade id is empty');
151
+ final formattedId = id.startsWith('0x') ? id.substring(2) : id;
152
+ final uri = Uri.https(_baseURL, '$_txInfoPath$formattedId');
153
+ final response = await http.get(uri);
154
+
155
+ if (response.statusCode == 404) {
156
+ throw Exception('Trade not found for id: $formattedId');
157
+ } else if (response.statusCode != 200) {
158
+ throw Exception('Unexpected HTTP status: ${response.statusCode}');
159
+ }
160
+
161
+ final responseJSON = json.decode(response.body);
162
+ final Map<String, dynamic> stagesJson = responseJSON['stages'] as Map<String, dynamic>;
163
+
164
+ final inboundObservedStarted = stagesJson['inbound_observed']?['started'] as bool? ?? true;
165
+ if (!inboundObservedStarted) {
166
+ throw Exception('Trade has not started for id: $formattedId');
167
+ }
168
+
169
+ final currentState = _updateStateBasedOnStages(stagesJson) ?? TradeState.notFound;
170
+
171
+ final tx = responseJSON['tx'];
172
+ final String fromAddress = tx['from_address'] as String? ?? '';
173
+ final String toAddress = tx['to_address'] as String? ?? '';
174
+ final List<dynamic> coins = tx['coins'] as List<dynamic>;
175
+ final String? memo = tx['memo'] as String?;
176
+
177
+ final parts = memo?.split(':') ?? [];
178
+
179
+ final String toChain = parts.length > 1 ? parts[1].split('.')[0] : '';
180
+ final String toAsset = parts.length > 1 && parts[1].split('.').length > 1 ? parts[1].split('.')[1].split('-')[0] : '';
181
+
182
+ final formattedToChain = CryptoCurrency.fromString(toChain);
183
+ final toAssetWithChain = CryptoCurrency.fromString(toAsset, walletCurrency:formattedToChain);
184
+
185
+ final plannedOutTxs = responseJSON['planned_out_txs'] as List<dynamic>?;
186
+ final isRefund = plannedOutTxs?.any((tx) => tx['refund'] == true) ?? false;
187
+
188
+ return Trade(
189
+ id: id,
190
+ from: CryptoCurrency.fromString(tx['chain'] as String? ?? ''),
191
+ to: toAssetWithChain,
192
+ provider: description,
193
+ inputAddress: fromAddress,
194
+ payoutAddress: toAddress,
195
+ amount: coins.first['amount'] as String? ?? '0.0',
196
+ state: currentState,
197
+ memo: memo,
198
+ isRefund: isRefund,
199
+ );
200
+ }
201
+
202
+ Future<Map<String, dynamic>> _getSwapQuote(Map<String, String> params) async {
203
+ Uri uri = Uri.https(_baseURL, _quotePath, params);
204
+
205
+ final response = await http.get(uri);
206
+
207
+ if (response.statusCode != 200) {
208
+ throw Exception('Unexpected HTTP status: ${response.statusCode}');
209
+ }
210
+
211
+ if (response.body.contains('error')) {
212
+ throw Exception('Unexpected response: ${response.body}');
213
+ }
214
+
215
+ return json.decode(response.body) as Map<String, dynamic>;
216
+ }
217
+
218
+ String _normalizeCurrency(CryptoCurrency currency) {
219
+ final networkTitle = currency.tag == 'ETH' ? 'ETH' : currency.title;
220
+ return '$networkTitle.${currency.title}';
221
+ }
222
+
223
+ String _doubleToThorChainString(double amount) => (amount * 1e8).toInt().toString();
224
+
225
+ double _thorChainAmountToDouble(String amount) => double.parse(amount) / 1e8;
226
+
227
+ TradeState? _updateStateBasedOnStages(Map<String, dynamic> stages) {
228
+ TradeState? currentState;
229
+
230
+ if (stages['inbound_observed']['completed'] as bool? ?? false) {
231
+ currentState = TradeState.confirmation;
232
+ }
233
+ if (stages['inbound_confirmation_counted']['completed'] as bool? ?? false) {
234
+ currentState = TradeState.confirmed;
235
+ }
236
+ if (stages['inbound_finalised']['completed'] as bool? ?? false) {
237
+ currentState = TradeState.processing;
238
+ }
239
+ if (stages['swap_finalised']['completed'] as bool? ?? false) {
240
+ currentState = TradeState.traded;
241
+ }
242
+ if (stages['outbound_signed']['completed'] as bool? ?? false) {
243
+ currentState = TradeState.success;
244
+ }
245
+
246
+ return currentState;
247
+ }
248
+}