dev
dart 622 lines 20 KB
Raw
1 import 'dart:convert';
2
3 import 'package:cake_wallet/.secrets.g.dart' as secrets;
4 import 'package:cake_wallet/core/utilities.dart';
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_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/amount_converter.dart';
13 import 'package:cw_core/utils/proxy_wrapper.dart';
14 import 'package:cw_core/crypto_currency.dart';
15 import 'package:cw_core/utils/print_verbose.dart';
16 import 'package:cake_wallet/utils/exchange_provider_logger.dart';
17
18 class NearIntentsExchangeProvider extends ExchangeProvider {
19 NearIntentsExchangeProvider();
20
21 static const apiKey = secrets.nearIntentsBearerToken;
22 static const _baseUrl = '1click.chaindefuser.com';
23 static const _versionPath = '/v0';
24 static const _tokenPath = '/tokens';
25 static const _quotePath = '/quote';
26 static const _statusPath = '/status';
27
28 static const _slippageTolerance = 100; // 1%
29 static const _appFeesNearIntents = secrets.nearIntentsAppFee;
30 static const _appFeeRecipientNearIntents = secrets.nearIntentsAppFeeRecipient;
31
32 static const _memoRequiredCurrencies = <CryptoCurrency>[
33 CryptoCurrency.xrp,
34 CryptoCurrency.xlm,
35 CryptoCurrency.ton,
36 ];
37
38 /// Use these only for quote/rate testing (dummy data).
39 static const Map<String, String> kNearDummyAddresses = {
40 // UTXO
41 'LTC': 'ltc1qhdwz74m3wuuhppv2mckagqk9e2e49z5j4kucnv',
42 'BTC': 'bc1qzwdt09dgr5nle2fkv7h5s6axgjqpdyp5g5tumz',
43 'DOGE': 'D9t7rGQ9mE3hJ2z1w8pGQxkGmKjYwYc8pQ',
44 'BCH': 'qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a',
45
46 // EVM (same address works for all EVM chains)
47 'ETH': '0x1111111111111111111111111111111111111111',
48 'BSC': '0x1111111111111111111111111111111111111111',
49 'POL': '0x1111111111111111111111111111111111111111',
50 'AVAXC': '0x1111111111111111111111111111111111111111',
51 'ARB': '0x1111111111111111111111111111111111111111',
52 'BASE': '0x1111111111111111111111111111111111111111',
53
54 // Others
55 'SOL': '11111111111111111111111111111111',
56 'XRP': 'rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe',
57 'TRX': 'T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb',
58 'TON': 'UQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c',
59 'XLM': 'GA3D5O2W7YQZJQ3H4Y5QW6N7V8X9Z0A1B2C3D4E5F6G7H8I9J0',
60 'ADA': 'addr1vyc33hdv5vag52f3d8h0qsngu52vm27x28zkzru333jma9gaxd38v',
61 'ZEC': 't1VdfFUbyTT7ZSdeEaHuwB7veGD1NXoUhGS',
62 };
63
64 String getNearDummyAddress(CryptoCurrency currency) {
65 final tag = (currency.tag ?? '').trim().toUpperCase();
66 final title = currency.title.toUpperCase();
67 final key = tag.isEmpty ? title : tag;
68 return kNearDummyAddresses[key] ?? '';
69 }
70
71 static final Map<String, String> _headers = {
72 'Accept': 'application/json',
73 'Content-Type': 'application/json',
74 'Authorization': '$apiKey',
75 };
76
77 static final _supportedTokensList = <Token>[];
78
79 @override
80 String get title => 'Near Intents';
81
82 @override
83 bool get isAvailable => true;
84
85 @override
86 bool get isEnabled => true;
87
88 @override
89 bool get supportsFixedRate => true;
90
91 @override
92 bool get supportsMemoOrDestinationTag => false;
93
94 @override
95 ExchangeProviderDescription get description => ExchangeProviderDescription.nearIntents;
96
97 @override
98 Future<bool> checkIsAvailable() async => true;
99
100 @override
101 Future<Limits?> fetchLimits(
102 {required CryptoCurrency from,
103 required CryptoCurrency to,
104 required bool isFixedRateMode}) async {
105 final tokens = await _geSupportedTokens();
106 final originToken = currencyToNearAssetId(from, tokens);
107 final destinationToken = currencyToNearAssetId(to, tokens);
108
109 if (originToken == null || destinationToken == null) {
110 throw Exception(
111 'fetchLimits: unsupported currency pair: ${from.title} ${from.tag ?? ''} to ${to.title} ${to.tag ?? ''}');
112 }
113
114 return Limits(min: null, max: null);
115 }
116
117 @override
118 Future<double> fetchRate({
119 required CryptoCurrency from,
120 required CryptoCurrency to,
121 required double amount,
122 required bool isFixedRateMode,
123 required bool isReceiveAmount,
124 }) async {
125 final tokens = await _geSupportedTokens();
126 final originToken = currencyToNearAssetId(from, tokens);
127 final destinationToken = currencyToNearAssetId(to, tokens);
128
129 try {
130 if (originToken == null || destinationToken == null) {
131 throw Exception('fetchRate: Unsupported currency pair');
132 }
133
134 final formattedAmount = AmountConverter.toBaseUnits(
135 amount.toString(), isFixedRateMode ? destinationToken.decimals : originToken.decimals);
136
137 final dummyAddrFrom = getNearDummyAddress(from);
138 final dummyAddrTo = getNearDummyAddress(to);
139
140 final depositMode = _memoRequiredCurrencies.contains(from) ? "MEMO" : "SIMPLE";
141
142 final quote = await getSwapQuote(
143 dry: true,
144 isFixedRateMode: isFixedRateMode,
145 originAsset: originToken.assetId,
146 destinationAsset: destinationToken.assetId,
147 amount: formattedAmount,
148 depositMode: depositMode,
149 refundTo: dummyAddrFrom,
150 recipient: dummyAddrTo,
151 );
152
153 if (quote == null) {
154 throw Exception('fetchRate: Quote returned null');
155 }
156
157 final q = quote['quote'] as Map<String, dynamic>;
158 final amountIn = double.tryParse(q['amountInFormatted']?.toString() ?? '0') ?? 0.0;
159 final amountOut = double.tryParse(q['amountOutFormatted']?.toString() ?? '0') ?? 0.0;
160
161 if (amountIn == 0) return 0.0;
162
163 final rate = amountOut / amountIn;
164
165 ExchangeProviderLogger.logSuccess(
166 provider: description,
167 function: 'fetchRate',
168 requestData: {
169 'from': from.title,
170 'to': to.title,
171 'amount': amount,
172 'formattedAmount': formattedAmount,
173 'isFixedRateMode': isFixedRateMode,
174 'isReceiveAmount': isReceiveAmount,
175 'originAsset': originToken.assetId,
176 'destinationAsset': destinationToken.assetId,
177 },
178 responseData: {
179 'amountIn': amountIn,
180 'amountOut': amountOut,
181 'rate': rate,
182 },
183 );
184
185 return rate;
186 } catch (e, s) {
187 ExchangeProviderLogger.logError(
188 provider: description,
189 function: 'fetchRate',
190 error: e,
191 stackTrace: s,
192 requestData: {
193 'from': from.title,
194 'to': to.title,
195 'amount': amount,
196 'isFixedRateMode': isFixedRateMode,
197 'isReceiveAmount': isReceiveAmount,
198 },
199 );
200 printV(e.toString());
201 return 0.0;
202 }
203 }
204
205 @override
206 Future<Trade> createTrade({
207 required TradeRequest request,
208 required bool isFixedRateMode,
209 required bool isSendAll,
210 }) async {
211 try {
212 final tokens = await _geSupportedTokens();
213 final originToken = currencyToNearAssetId(request.fromCurrency, tokens);
214 final destinationToken = currencyToNearAssetId(request.toCurrency, tokens);
215
216 if (originToken == null || destinationToken == null) {
217 throw Exception('Unsupported currency pair');
218 }
219
220 final rawAmountStr = isFixedRateMode ? request.toAmount : request.fromAmount;
221
222 final baseAmount = AmountConverter.toBaseUnits(
223 rawAmountStr,
224 isFixedRateMode ? request.toCurrency.decimals : request.fromCurrency.decimals,
225 );
226
227 final depositMode =
228 _memoRequiredCurrencies.contains(request.fromCurrency) ? "MEMO" : "SIMPLE";
229
230 final quote = await getSwapQuote(
231 dry: false,
232 isFixedRateMode: isFixedRateMode,
233 originAsset: originToken.assetId,
234 destinationAsset: destinationToken.assetId,
235 depositMode: depositMode,
236 amount: baseAmount,
237 refundTo: request.refundAddress,
238 recipient: request.toAddress,
239 );
240
241 if (quote == null) {
242 throw Exception('Quote request failed');
243 }
244
245 final quoteObj = quote['quote'] as Map<String, dynamic>;
246 final depositAddress = quoteObj['depositAddress'] as String;
247 final depositMemo = quoteObj['depositMemo'] as String?;
248 final depositAmount = quoteObj['amountInFormatted'] as String?;
249
250 if (depositAmount == null) {
251 throw Exception('Deposit amount is null in quote response');
252 }
253
254 final quoteRequest = quote['quoteRequest'] as Map<String, dynamic>;
255 final fromAssetId = quoteRequest['originAsset'] as String;
256 final toAssetId = quoteRequest['destinationAsset'] as String;
257
258 final fromCurrency = _nearAssetIdToCurrency(fromAssetId, tokens);
259 if (fromCurrency == null) {
260 throw Exception('Failed to parse from currency from assetId: $fromAssetId');
261 }
262
263 final toCurrency = _nearAssetIdToCurrency(toAssetId, tokens);
264 if (toCurrency == null) {
265 throw Exception('Failed to parse to currency from assetId: $toAssetId');
266 }
267
268 final from =
269 CryptoCurrency.safeParseCurrencyFromString(fromCurrency.$1, tag: fromCurrency.$2);
270 final to = CryptoCurrency.safeParseCurrencyFromString(toCurrency.$1, tag: toCurrency.$2);
271
272 final trade = Trade(
273 id: depositAddress,
274 // Using deposit address as trade ID
275 from: request.fromCurrency,
276 to: request.toCurrency,
277 provider: description,
278 providerName: title,
279 state: TradeState.created,
280 createdAt: DateTime.now(),
281 inputAddress: depositAddress,
282 payoutAddress: request.toAddress,
283 refundAddress: request.refundAddress,
284 amount: depositAmount,
285 receiveAmount: quoteObj['amountOutFormatted']?.toString(),
286 memo: depositMemo,
287 isSendAll: isSendAll,
288 );
289
290 ExchangeProviderLogger.logSuccess(
291 provider: description,
292 function: 'createTrade',
293 requestData: {
294 'from': request.fromCurrency.title,
295 'to': request.toCurrency.title,
296 'fromAmount': request.fromAmount,
297 'toAmount': request.toAmount,
298 'refundAddress': request.refundAddress,
299 'recipient': request.toAddress,
300 'isFixedRateMode': isFixedRateMode,
301 'isSendAll': isSendAll,
302 'originAsset': originToken.assetId,
303 'destinationAsset': destinationToken.assetId,
304 },
305 responseData: {
306 'correlationId': quote['correlationId'],
307 'depositAddress': depositAddress,
308 'depositMemo': depositMemo,
309 'quote': quoteObj,
310 },
311 );
312
313 return trade;
314 } catch (e, s) {
315 ExchangeProviderLogger.logError(
316 provider: description,
317 function: 'createTrade',
318 error: e,
319 stackTrace: s,
320 requestData: {
321 'from': request.fromCurrency.title,
322 'to': request.toCurrency.title,
323 'fromAmount': request.fromAmount,
324 'toAmount': request.toAmount,
325 'refundAddress': request.refundAddress,
326 'recipient': request.toAddress,
327 'isFixedRateMode': isFixedRateMode,
328 'isSendAll': isSendAll,
329 },
330 );
331 throw TradeNotCreatedException(description, description: e.toString());
332 }
333 }
334
335 @override
336 Future<Trade> findTradeById({required String id}) async {
337 final param = {'depositAddress': id};
338 final uri = Uri.https(_baseUrl, '$_versionPath$_statusPath', param);
339
340 final response = await ProxyWrapper().get(clearnetUri: uri, headers: _headers);
341
342 if (response.statusCode != 200) {
343 throw Exception(
344 'Near Intents fetch trade failed: ${response.statusCode} ${response.body}',
345 );
346 }
347
348 final data = jsonDecode(response.body) as Map<String, dynamic>;
349 final statusRaw = (data['status'] as String?) ?? 'UNKNOWN';
350
351 final quoteResponse = data['quoteResponse'] as Map<String, dynamic>? ?? {};
352 final quoteRequest = quoteResponse['quoteRequest'] as Map<String, dynamic>? ?? {};
353
354 final refundTo = quoteRequest['refundTo'] as String? ?? '';
355 final recipient = quoteRequest['recipient'] as String? ?? '';
356
357 // Parsing 'from' currency
358 final originAssetId = quoteRequest['originAsset'] as String? ?? '';
359 final from = _nearAssetIdToCurrency(originAssetId, await _geSupportedTokens());
360
361 CryptoCurrency? coinFrom;
362 CryptoCurrency? coinTo;
363
364 if (from != null) {
365 coinFrom = CryptoCurrency.safeParseCurrencyFromString(from.$1, tag: from.$2);
366 }
367
368 // Parsing 'to' currency
369 final destinationAssetId = quoteRequest['destinationAsset'] as String? ?? '';
370
371 final to = _nearAssetIdToCurrency(destinationAssetId, await _geSupportedTokens());
372
373 if (to != null) {
374 coinTo = CryptoCurrency.safeParseCurrencyFromString(to.$1, tag: to.$2);
375 }
376
377 final quote = quoteResponse['quote'] as Map<String, dynamic>? ?? {};
378 final swap = data['swapDetails'] as Map<String, dynamic>? ?? {};
379
380 final depositAddress = quote['depositAddress'] as String?;
381 final depositMemo = quote['depositMemo'] as String?;
382
383 final depositAmount =
384 swap['amountInFormatted']?.toString() ?? quote['amountInFormatted']?.toString() ?? '0';
385
386 final receiveAmount =
387 swap['amountOutFormatted']?.toString() ?? quote['amountOutFormatted']?.toString();
388
389 final originTxHash = (swap['originChainTxHashes'] as List?)?.firstOrNull?['hash']?.toString();
390
391 return Trade(
392 id: id,
393 from: coinFrom,
394 to: coinTo,
395 provider: description,
396 inputAddress: depositAddress,
397 payoutAddress: recipient,
398 refundAddress: refundTo,
399 amount: depositAmount,
400 receiveAmount: receiveAmount,
401 state: _normalizeStatusToTradeState(statusRaw),
402 txId: originTxHash,
403 extraId: depositMemo,
404 isRefund: statusRaw == 'REFUNDED',
405 );
406 }
407
408 // Load & cache supported tokens
409 Future<List<Token>> _geSupportedTokens() async {
410 if (_supportedTokensList.isNotEmpty) return _supportedTokensList;
411
412 try {
413 final uri = Uri.https(_baseUrl, '$_versionPath$_tokenPath');
414
415 final response = await ProxyWrapper().get(clearnetUri: uri, headers: _headers);
416 if (response.statusCode != 200) return [];
417
418 final data = json.decode(response.body) as List<dynamic>;
419 _supportedTokensList
420 ..clear()
421 ..addAll(data.map((e) => Token.fromJson(e as Map<String, dynamic>)));
422 return _supportedTokensList;
423 } catch (e) {
424 printV(e);
425 return [];
426 }
427 }
428
429 Future<Map<String, dynamic>?> getSwapQuote({
430 required bool dry,
431 required bool isFixedRateMode,
432 required String originAsset,
433 required String destinationAsset,
434 required String amount,
435 required String refundTo,
436 required String recipient,
437 required String depositMode,
438 List<String>? connectedWallets,
439 String? sessionId,
440 String? virtualChainRecipient,
441 String? virtualChainRefundRecipient,
442 String? customRecipientMsg,
443 String? deadline,
444 String? referral,
445 int? quoteWaitingTimeMs,
446 }) async {
447 final swapType = isFixedRateMode ? 'EXACT_OUTPUT' : 'EXACT_INPUT';
448 final _isoUtcDeadline = _buildDeadline();
449 final appFees = [
450 {
451 "recipient": _appFeeRecipientNearIntents,
452 "fee": _appFeesNearIntents,
453 }
454 ];
455
456 final uri = Uri.https(_baseUrl, "$_versionPath$_quotePath");
457
458 final payload = {
459 "dry": dry,
460 "depositMode": depositMode,
461 "swapType": swapType,
462 "slippageTolerance": _slippageTolerance,
463 "originAsset": originAsset,
464 "depositType": 'ORIGIN_CHAIN',
465 "destinationAsset": destinationAsset,
466 "amount": amount,
467 "refundTo": refundTo,
468 "refundType": 'ORIGIN_CHAIN',
469 "recipient": recipient,
470 "recipientType": 'DESTINATION_CHAIN',
471 "deadline": _isoUtcDeadline,
472 if (connectedWallets != null) "connectedWallets": connectedWallets,
473 if (sessionId != null) "sessionId": sessionId,
474 if (virtualChainRecipient != null) "virtualChainRecipient": virtualChainRecipient,
475 if (virtualChainRefundRecipient != null)
476 "virtualChainRefundRecipient": virtualChainRefundRecipient,
477 if (customRecipientMsg != null) "customRecipientMsg": customRecipientMsg,
478 if (deadline != null) "deadline": deadline,
479 if (referral != null) "referral": referral,
480 if (quoteWaitingTimeMs != null) "quoteWaitingTimeMs": quoteWaitingTimeMs,
481 "appFees": appFees,
482 };
483
484 try {
485 final response = await ProxyWrapper().post(
486 clearnetUri: uri,
487 headers: _headers,
488 body: jsonEncode(payload),
489 );
490
491 if (response.statusCode != 201) {
492 printV("Quote request failed with status: ${response.statusCode}");
493 return null;
494 }
495
496 return jsonDecode(response.body) as Map<String, dynamic>;
497 } catch (e) {
498 printV("Quote error: $e");
499 return null;
500 }
501 }
502
503 TradeState _normalizeStatusToTradeState(String status) {
504 return switch (status.toUpperCase()) {
505 'PENDING_DEPOSIT' => TradeState.pending,
506 'PROCESSING' => TradeState.processing,
507 'SUCCESS' => TradeState.success,
508 'INCOMPLETE_DEPOSIT' => TradeState.underpaid,
509 'REFUNDED' => TradeState.refunded,
510 'FAILED' => TradeState.failed,
511 _ => TradeState.notFound,
512 };
513 }
514
515 String? _normalizeTagToNearBlockchain(String? tag) {
516 return switch (tag) {
517 'TRX' => 'tron',
518 'AVAXC' => 'avax',
519 _ => tag?.toLowerCase(),
520 };
521 }
522
523 String? _normalizeNearBlockchainToTag(String? blockchain) {
524 return switch (blockchain) {
525 'tron' => 'TRX',
526 'avax' => 'AVAXC',
527 _ => blockchain?.toUpperCase(),
528 };
529 }
530
531 Token? currencyToNearAssetId(CryptoCurrency currency, List<Token> supported) {
532 if (supported.isEmpty) return null;
533
534 final symbol = currency.title.toUpperCase();
535 final blockchain = _normalizeTagToNearBlockchain(currency.tag);
536
537 // Use the native Bitcoin asset routed through Omni Bridge.
538 if (currency == CryptoCurrency.btc) {
539 return supported.firstWhereOrNull(
540 (t) => t.assetId == '1cs_v1:btc:native:coin',
541 );
542 }
543
544 // Native asset (no contract)
545 final native = supported.firstWhereOrNull((t) =>
546 t.symbol.toUpperCase() == symbol &&
547 (blockchain == null || t.blockchain.toLowerCase() == blockchain) &&
548 t.contractAddress == null);
549
550 if (native != null) {
551 return native;
552 }
553
554 final token = supported.firstWhereOrNull((t) =>
555 t.symbol.toUpperCase() == symbol &&
556 (blockchain == null || t.blockchain.toLowerCase() == blockchain));
557
558 return token;
559 }
560
561 (String, String?)? _nearAssetIdToCurrency(String assetId, List<Token> supported) {
562 if (supported.isEmpty) return null;
563
564 final token = supported.firstWhereOrNull((t) => t.assetId == assetId);
565
566 if (token == null) return null;
567
568 final title = token.symbol.toUpperCase().replaceAll(RegExp(r'\s*\([^)]*\)'), '');
569
570 final normalizedNetwork = _normalizeNearBlockchainToTag(token.blockchain);
571
572 final isNativeAsset = assetId.contains(':native:coin');
573
574 final tag = isNativeAsset || normalizedNetwork == title ? null : normalizedNetwork;
575
576 return (title, tag);
577 }
578
579 String _buildDeadline() {
580 return DateTime.now()
581 .toUtc()
582 .add(const Duration(hours: 1))
583 .toIso8601String()
584 .replaceFirst(RegExp(r'\.\d+Z$'), 'Z');
585 }
586 }
587
588 class Token {
589 final String assetId;
590 final int decimals;
591 final String blockchain;
592 final String symbol;
593 final double priceUsd;
594 final String? priceUpdatedAt;
595 final String? contractAddress;
596
597 Token({
598 required this.assetId,
599 required this.decimals,
600 required this.blockchain,
601 required this.symbol,
602 required this.priceUsd,
603 required this.priceUpdatedAt,
604 required this.contractAddress,
605 });
606
607 factory Token.fromJson(Map<String, dynamic> json) {
608 final decimals = json['decimals'] as int?;
609 if (decimals == null) {
610 throw Exception('Token decimals is null for assetId: ${json['assetId']}');
611 }
612 return Token(
613 assetId: json['assetId'] as String,
614 decimals: json['decimals'] as int,
615 blockchain: json['blockchain'] as String,
616 symbol: json['symbol'] as String,
617 priceUsd: (json['price'] as num?)?.toDouble() ?? 0.0,
618 priceUpdatedAt: json['priceUpdatedAt'] as String?,
619 contractAddress: json['contractAddress'] as String?,
620 );
621 }
622 }