dev
dart 439 lines 14 KB
Raw
1 import 'dart:convert';
2 import 'dart:math';
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_request.dart';
10 import 'package:cake_wallet/exchange/trade_state.dart';
11 import 'package:cw_core/crypto_currency.dart';
12 import 'package:cw_core/utils/print_verbose.dart';
13 import 'package:cw_core/utils/proxy_wrapper.dart';
14 import 'package:cake_wallet/utils/exchange_provider_logger.dart';
15
16 class ChainflipExchangeProvider extends ExchangeProvider {
17 ChainflipExchangeProvider();
18
19 static final List<CryptoCurrency> _supported = [
20 CryptoCurrency.btc,
21 CryptoCurrency.eth,
22 CryptoCurrency.usdc,
23 CryptoCurrency.usdterc20,
24 CryptoCurrency.flip,
25 CryptoCurrency.wbtc,
26 CryptoCurrency.sol,
27 CryptoCurrency.usdcsol,
28 CryptoCurrency.usdtSol,
29 CryptoCurrency.arbEth,
30 CryptoCurrency.usdcArb,
31 CryptoCurrency.usdtArb,
32 CryptoCurrency.trx,
33 CryptoCurrency.usdttrc20,
34 ];
35
36 static const _baseURL = 'chainflip-broker.io';
37 static const _assetsPath = '/assets';
38 static const _quotePath = '/quotes-native';
39 static const _swapPath = '/swap';
40 static const _txInfoPath = '/status-by-deposit-channel';
41 static const _affiliateBps = secrets.chainflipAffiliateFee;
42 static const _affiliateKey = secrets.chainflipApiKey;
43
44 @override
45 String get title => 'Chainflip';
46
47 @override
48 bool get isAvailable => true;
49
50 @override
51 bool get isEnabled => true;
52
53 @override
54 bool get supportsFixedRate => false;
55
56 @override
57 bool get supportsMemoOrDestinationTag => false;
58
59 @override
60 ExchangeProviderDescription get description => ExchangeProviderDescription.chainflip;
61
62 @override
63 Future<bool> checkIsAvailable() async => true;
64
65 @override
66 Future<Limits?> fetchLimits(
67 {required CryptoCurrency from,
68 required CryptoCurrency to,
69 required bool isFixedRateMode}) async {
70 try {
71 if (!_supported.contains(from) || !_supported.contains(to)) {
72 throw Exception('No rates found for $from to $to');
73 }
74
75 final assetId = _normalizeCurrency(from);
76
77 final assetsResponse = await _getAssets();
78 final assets = assetsResponse['assets'] as List<dynamic>;
79
80 final minAmount = assets.firstWhere((asset) => asset['id'] == assetId,
81 orElse: () => null)?['minimalAmountNative'] ??
82 '0';
83
84 if (minAmount == '0') throw Exception('No rates found for $from to $to');
85
86 return Limits(min: _amountFromNative(minAmount.toString(), from));
87 } catch (e) {
88 printV(e.toString());
89 throw Exception('Chainflip failed to fetch limits');
90 }
91 }
92
93 @override
94 Future<double> fetchRate(
95 {required CryptoCurrency from,
96 required CryptoCurrency to,
97 required double amount,
98 required bool isFixedRateMode,
99 required bool isReceiveAmount}) async {
100 // TODO: It seems this rate is getting cached, and re-used for different amounts, can we not do this?
101
102 try {
103 if (amount == 0) return 0.0;
104
105 if (!_supported.contains(from) || !_supported.contains(to)) return 0.0;
106
107 final quoteParams = {
108 'apiKey': _affiliateKey,
109 'sourceAsset': _normalizeCurrency(from),
110 'destinationAsset': _normalizeCurrency(to),
111 'amount': _amountToNative(amount, from),
112 'commissionBps': _affiliateBps
113 };
114
115 final quoteResponse = await _getSwapQuote(quoteParams);
116
117 final expectedAmountOut = quoteResponse['egressAmountNative'] as String? ?? '0';
118
119 final rate = _amountFromNative(expectedAmountOut, to) / amount;
120
121 ExchangeProviderLogger.logSuccess(
122 provider: description,
123 function: 'fetchRate',
124 requestData: {
125 'from': from.title,
126 'to': to.title,
127 'amount': amount,
128 'isFixedRateMode': isFixedRateMode,
129 'isReceiveAmount': isReceiveAmount,
130 'quoteParams': quoteParams,
131 },
132 responseData: {
133 'expectedAmountOut': expectedAmountOut,
134 'rate': rate,
135 'quoteResponse': quoteResponse,
136 },
137 );
138
139 return rate;
140 } catch (e, s) {
141 ExchangeProviderLogger.logError(
142 provider: description,
143 function: 'fetchRate',
144 error: e,
145 stackTrace: s,
146 requestData: {
147 'from': from.title,
148 'to': to.title,
149 'amount': amount,
150 'isFixedRateMode': isFixedRateMode,
151 'isReceiveAmount': isReceiveAmount,
152 },
153 );
154 printV(e.toString());
155 return 0.0;
156 }
157 }
158
159 @override
160 Future<Trade> createTrade(
161 {required TradeRequest request,
162 required bool isFixedRateMode,
163 required bool isSendAll}) async {
164 try {
165 final maxSlippage = 2;
166
167 final quoteParams = {
168 'apiKey': _affiliateKey,
169 'sourceAsset': _normalizeCurrency(request.fromCurrency),
170 'destinationAsset': _normalizeCurrency(request.toCurrency),
171 'amount': _amountToNative(double.parse(request.fromAmount), request.fromCurrency),
172 'commissionBps': _affiliateBps
173 };
174
175 final quoteResponse = await _getSwapQuote(quoteParams);
176 final estimatedPrice = quoteResponse['estimatedPrice'] as double;
177 final minimumPrice = estimatedPrice * (100 - maxSlippage) / 100;
178
179 final swapParams = {
180 'apiKey': _affiliateKey,
181 'sourceAsset': _normalizeCurrency(request.fromCurrency),
182 'destinationAsset': _normalizeCurrency(request.toCurrency),
183 'destinationAddress': request.toAddress,
184 'commissionBps': _affiliateBps,
185 'minimumPrice': minimumPrice.toString(),
186 'refundAddress': request.refundAddress,
187 'boostFee': '6',
188 'retryDurationInBlocks': '150'
189 };
190
191 if (quoteResponse.containsKey('numberOfChunks') &&
192 quoteResponse.containsKey('chunkIntervalBlocks')) {
193 swapParams.addAll({
194 'numberOfChunks': quoteResponse['numberOfChunks'].toString(),
195 'chunkIntervalBlocks': quoteResponse['chunkIntervalBlocks'].toString(),
196 });
197 }
198
199 final swapResponse = await _openDepositChannel(swapParams);
200
201 final id =
202 '${swapResponse['issuedBlock']}-${swapResponse['network'].toString()}-${swapResponse['channelId']}';
203
204 ExchangeProviderLogger.logSuccess(
205 provider: description,
206 function: 'createTrade',
207 requestData: {
208 'from': request.fromCurrency.title,
209 'to': request.toCurrency.title,
210 'fromAmount': request.fromAmount,
211 'toAmount': request.toAmount,
212 'toAddress': request.toAddress,
213 'refundAddress': request.refundAddress,
214 'isFixedRateMode': isFixedRateMode,
215 'isSendAll': isSendAll,
216 'quoteParams': quoteParams,
217 'swapParams': swapParams,
218 },
219 responseData: {
220 'id': id,
221 'inputAddress': swapResponse['address'].toString(),
222 'estimatedPrice': estimatedPrice,
223 'minimumPrice': minimumPrice,
224 'swapResponse': swapResponse,
225 },
226 );
227
228 return Trade(
229 id: id,
230 from: request.fromCurrency,
231 to: request.toCurrency,
232 provider: description,
233 inputAddress: swapResponse['address'].toString(),
234 createdAt: DateTime.now(),
235 amount: request.fromAmount,
236 receiveAmount: request.toAmount,
237 state: TradeState.waiting,
238 payoutAddress: request.toAddress,
239 isSendAll: isSendAll,
240 );
241 } catch (e, s) {
242 ExchangeProviderLogger.logError(
243 provider: description,
244 function: 'createTrade',
245 error: e,
246 stackTrace: s,
247 requestData: {
248 'from': request.fromCurrency.title,
249 'to': request.toCurrency.title,
250 'fromAmount': request.fromAmount,
251 'toAmount': request.toAmount,
252 'toAddress': request.toAddress,
253 'refundAddress': request.refundAddress,
254 'isFixedRateMode': isFixedRateMode,
255 'isSendAll': isSendAll,
256 },
257 );
258 printV(e.toString());
259 rethrow;
260 }
261 }
262
263 @override
264 Future<Trade> findTradeById({required String id}) async {
265 try {
266 final channelParts = id.split('-');
267 final network = channelParts[1];
268 final normalizedNetwork = _normalizeNetworkName(network);
269
270 final statusParams = {
271 'apiKey': _affiliateKey,
272 'issuedBlock': channelParts[0],
273 'network': normalizedNetwork,
274 'channelId': channelParts[2]
275 };
276
277 final statusResponse = await _getStatus(statusParams);
278
279 if (statusResponse == null) throw Exception('Trade not found for id: $id');
280
281 final status = statusResponse['status'];
282 final currentState = _determineState(status['state'].toString());
283
284 final depositAmount = status['deposit']?['amount']?.toString() ?? '0.0';
285 final receiveAmount = status['swapEgress']?['amount']?.toString() ?? '0.0';
286 final refundAmount = status['refundEgress']?['amount']?.toString() ?? '0.0';
287 final isRefund = status['refundEgress'] != null;
288 final amount = isRefund ? refundAmount : receiveAmount;
289
290 final from = status['sourceAsset'].toString();
291 final to = status['destinationAsset'].toString();
292
293 final newTrade = Trade(
294 id: id,
295 from: _toCurrency(from),
296 to: _toCurrency(to),
297 provider: description,
298 amount: depositAmount,
299 receiveAmount: amount,
300 state: currentState,
301 payoutAddress: status['destinationAddress'].toString(),
302 outputTransaction: status['swapEgress']?['transactionReference']?.toString(),
303 isRefund: isRefund,
304 );
305
306 return newTrade;
307 } catch (e) {
308 printV(e.toString());
309 rethrow;
310 }
311 }
312
313 String _normalizeCurrency(CryptoCurrency currency) {
314 // Chainflip uses 'tron' as the network slug, but Cake uses tag 'TRX' (and null
315 // for native TRX), so these can't go through the generic title.tag logic below.
316 if (currency == CryptoCurrency.trx) return 'trx.tron';
317 if (currency == CryptoCurrency.usdttrc20) return 'usdt.tron';
318
319 final tag = currency.tag?.toLowerCase();
320 final title = currency.title.toLowerCase();
321
322 // Naive assets without network tag
323 if (tag == null) return '$title.$title';
324
325 return '$title.$tag';
326 }
327
328 String _normalizeNetworkName(String name) {
329 final networkName = switch (name) {
330 'BITCOIN' => 'Bitcoin',
331 'ETHEREUM' => 'Ethereum',
332 'ARBITRUM' => 'Arbitrum',
333 'SOLANA' => 'Solana',
334 'TRON' => 'Tron',
335 _ => name
336 };
337
338 return networkName;
339 }
340
341 CryptoCurrency? _toCurrency(String name) {
342 final currency = switch (name) {
343 'btc.btc' => CryptoCurrency.btc,
344 'eth.eth' => CryptoCurrency.eth,
345 'usdc.eth' => CryptoCurrency.usdc,
346 'usdt.eth' => CryptoCurrency.usdterc20,
347 'flip.eth' => CryptoCurrency.flip,
348 'wbtc.eth' => CryptoCurrency.wbtc,
349 'sol.sol' => CryptoCurrency.sol,
350 'usdc.sol' => CryptoCurrency.usdcsol,
351 'eth.arb' => CryptoCurrency.arbEth,
352 'usdc.arb' => CryptoCurrency.usdcArb,
353 'usdt.arb' => CryptoCurrency.usdtArb,
354 'trx.tron' => CryptoCurrency.trx,
355 'usdt.tron' => CryptoCurrency.usdttrc20,
356 _ => null
357 };
358
359 return currency;
360 }
361
362 String _amountToNative(double amount, CryptoCurrency currency) =>
363 (amount * pow(10, currency.decimals)).toInt().toString();
364
365 double _amountFromNative(String amount, CryptoCurrency currency) =>
366 double.parse(amount) / pow(10, currency.decimals);
367
368 Future<Map<String, dynamic>> _getAssets() async => _getRequest(_assetsPath, {});
369
370 Future<Map<String, dynamic>> _openDepositChannel(Map<String, String> params) async =>
371 _getRequest(_swapPath, params);
372
373 Future<Map<String, dynamic>> _getRequest(String path, Map<String, String> params) async {
374 final uri = Uri.https(_baseURL, path, params);
375
376 final response = await ProxyWrapper().get(clearnetUri: uri);
377
378 if ((response.statusCode != 200) || (response.body.contains('error'))) {
379 throw Exception(
380 'Unexpected response: ${response.statusCode} / ${uri.toString()} / ${response.body}');
381 }
382
383 return json.decode(response.body) as Map<String, dynamic>;
384 }
385
386 Future<Map<String, dynamic>> _getSwapQuote(Map<String, String> params) async {
387 final uri = Uri.https(_baseURL, _quotePath, params);
388
389 final response = await ProxyWrapper().get(clearnetUri: uri);
390
391 if ((response.statusCode != 200) || (response.body.contains('error'))) {
392 throw Exception(
393 'Unexpected response: ${response.statusCode} / ${uri.toString()} / ${response.body}');
394 }
395
396 final List<dynamic> jsonList = json.decode(response.body) as List<dynamic>;
397 final List<Map<String, dynamic>> quotes =
398 jsonList.map((e) => e as Map<String, dynamic>).toList();
399
400 Map<String, dynamic> highestQuote = quotes.reduce((current, next) {
401 double currentAmount = current['egressAmount'] as double;
402 double nextAmount = next['egressAmount'] as double;
403
404 return currentAmount > nextAmount ? current : next;
405 });
406
407 return highestQuote;
408 }
409
410 Future<Map<String, dynamic>?> _getStatus(Map<String, String> params) async {
411 final uri = Uri.https(_baseURL, _txInfoPath, params);
412
413 final response = await ProxyWrapper().get(clearnetUri: uri);
414
415 if (response.statusCode == 404) return null;
416
417 if ((response.statusCode != 200) || (response.body.contains('error'))) {
418 throw Exception(
419 'Unexpected response: ${response.statusCode} / ${uri.toString()} / ${response.body}');
420 }
421
422 return json.decode(response.body) as Map<String, dynamic>;
423 }
424
425 TradeState _determineState(String state) {
426 final swapState = switch (state) {
427 'waiting' => TradeState.waiting,
428 'receiving' => TradeState.processing,
429 'swapping' => TradeState.processing,
430 'sending' => TradeState.processing,
431 'sent' => TradeState.processing,
432 'completed' => TradeState.success,
433 'failed' => TradeState.failed,
434 _ => TradeState.notFound
435 };
436
437 return swapState;
438 }
439 }