dev
dart 835 lines 27 KB
Raw
1 import 'dart:convert';
2
3 import 'package:cake_wallet/.secrets.g.dart' as secrets;
4 import 'package:cake_wallet/exchange/provider/exchange_provider.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/trade.dart';
8 import 'package:cake_wallet/exchange/trade_not_created_exception.dart';
9 import 'package:cake_wallet/exchange/trade_request.dart';
10 import 'package:cake_wallet/exchange/trade_state.dart';
11 import 'package:cw_core/amount_converter.dart';
12 import 'package:cw_core/utils/print_verbose.dart';
13 import 'package:cw_core/utils/proxy_wrapper.dart';
14 import 'package:cw_core/crypto_currency.dart';
15
16 class SwapsXyzExchangeProvider extends ExchangeProvider {
17 SwapsXyzExchangeProvider();
18
19 static final List<CryptoCurrency> _notSupportedAsSourceToken = [
20 CryptoCurrency.sol,
21 ...CryptoCurrency.all.where(
22 (c) => (c.tag ?? '').toUpperCase() == 'SOL' || c.tag == CryptoCurrency.bnb.tag,
23 ),
24 ];
25
26 static const _transferSig = '0xa9059cbb';
27 static const _swapAndExecuteSig = '0x9be111d1';
28
29 static final _apiKey = secrets.swapsXyzApiKey;
30 static const _baseUrl = 'api-v2.swaps.xyz';
31 static const _getChainList = 'api/getChainList';
32 static const _getPaths = 'api/getPaths';
33 static const _getQuotePaths = 'api/getQuote';
34 static const _getAction = 'api/getAction';
35 static const _registerTxs = 'api/registerTxs';
36 static const _getStatus = 'api/getStatus';
37
38 static final _headers = {'x-api-key': _apiKey};
39
40 static final _supportedChainList = <Chain>[];
41 final Map<int, List<TokenPathInfo>> _tokensCache = {};
42
43 @override
44 String get title => 'Swaps.XYZ';
45
46 @override
47 bool get isAvailable => true;
48
49 @override
50 bool get isEnabled => true;
51
52 // There is an issue with fixed-rate swaps on Swaps.XYZ
53 // when they don't guarantee that the trade will be created at the requested amount.
54 @override
55 bool get supportsFixedRate => false;
56
57 @override
58 bool get supportsMemoOrDestinationTag => false;
59
60 @override
61 ExchangeProviderDescription get description => ExchangeProviderDescription.swapsXyz;
62
63 @override
64 Future<bool> checkIsAvailable() async => true;
65
66 @override
67 Future<Limits?> fetchLimits({
68 required CryptoCurrency from,
69 required CryptoCurrency to,
70 required bool isFixedRateMode,
71 }) async {
72 try {
73 final chains = await _geSupportedChain();
74 if (chains.isEmpty) throw Exception('Failed to fetch supported chains');
75
76 final fromToUse = isFixedRateMode ? to : from;
77 final toToUse = isFixedRateMode ? from : to;
78
79 final srcChain = _findChainByCurrency(fromToUse, chains);
80 final dstChain = _findChainByCurrency(toToUse, chains);
81
82 await _ensureTokensCached(
83 fromChain: srcChain, toChain: dstChain, from: fromToUse, to: toToUse);
84
85 final srcToken = _getTokenAddress(currency: fromToUse, chain: srcChain);
86 final dstToken = _getTokenAddress(currency: toToUse, chain: dstChain);
87
88 final params = {
89 'srcChainId': '${srcChain.chainId}',
90 'srcToken': srcToken,
91 'dstChainId': '${dstChain.chainId}',
92 'dstToken': dstToken,
93 };
94
95 final uri = Uri.https(_baseUrl, _getPaths, params);
96 final res = await ProxyWrapper().get(clearnetUri: uri, headers: _headers);
97 if (res.statusCode != 200) {
98 throw Exception('Unexpected http status: ${res.statusCode}');
99 }
100
101 final body = json.decode(res.body) as Map<String, dynamic>;
102
103 final paths = (body['paths'] as List? ?? const []).cast<Map<String, dynamic>>();
104 if (paths.isEmpty) {
105 throw Exception('No paths for ${fromToUse.title} -> ${toToUse.title}');
106 }
107
108 final int requestedDstId = dstChain.chainId;
109
110 Map<String, dynamic> path = paths.firstWhere(
111 (p) => p['chainId'] == requestedDstId,
112 orElse: () => <String, dynamic>{},
113 );
114
115 if (path.isEmpty) {
116 path = paths.firstWhere(
117 (p) => (p['tokens'] is List) || p['amountLimits'] != null,
118 orElse: () => paths.first,
119 );
120 }
121
122 final supportsExactAmountIn = path['supportsExactAmountIn'] as bool? ?? false;
123 final supportsExactAmountOut = path['supportsExactAmountOut'] as bool? ?? false;
124
125 if (isFixedRateMode && !supportsExactAmountOut) {
126 throw Exception('This route does not support fixed receive (exact-amount-out)');
127 }
128 if (!isFixedRateMode && !supportsExactAmountIn) {
129 throw Exception('This route does not support exact send (exact-amount-in)');
130 }
131
132 Map<String, dynamic>? useLimits;
133
134 if (isFixedRateMode) {
135 final tokensField = path['tokens'];
136 useLimits = null;
137
138 if (tokensField is List && tokensField.isNotEmpty) {
139 final tokens = tokensField.cast<Map<String, dynamic>>();
140 String norm(String s) => s.toUpperCase();
141 final wantSym = norm(_normalizeCakeNativeTokenName(toToUse.title));
142 final wantAddr = (dstToken).toLowerCase();
143
144 final match = tokens.firstWhere(
145 (t) {
146 final sym = norm(t['symbol']?.toString() ?? '');
147 final addr = (t['address']?.toString() ?? '').toLowerCase();
148 return sym == wantSym || (addr.isNotEmpty && addr == wantAddr);
149 },
150 orElse: () => const <String, dynamic>{},
151 );
152
153 if (match.isNotEmpty) {
154 useLimits = {
155 'minAmount': match['minAmount'],
156 'maxAmount': match['maxAmount'],
157 };
158 }
159 }
160 } else {
161 // Floating/Exact-in: use the route-level limits
162 useLimits = path['amountLimits'] as Map<String, dynamic>?;
163 }
164
165 final min = double.tryParse((useLimits?['minAmount'])?.toString() ?? '');
166 final max = double.tryParse((useLimits?['maxAmount'])?.toString() ?? '');
167 return Limits(min: min, max: max);
168 } catch (e) {
169 printV('fetchLimits error: $e');
170 throw Exception('Error fetching limits: $e');
171 }
172 }
173
174 Future<_PathInfo?> _pickPath({
175 required int srcChainId,
176 required String srcToken,
177 required int dstChainId,
178 required String dstToken,
179 }) async {
180 final uri = Uri.https(_baseUrl, _getPaths, {
181 'srcChainId': '$srcChainId',
182 'srcToken': srcToken,
183 'dstChainId': '$dstChainId',
184 'dstToken': dstToken,
185 });
186 final res = await ProxyWrapper().get(clearnetUri: uri, headers: _headers);
187 if (res.statusCode != 200) return null;
188 final body = json.decode(res.body) as Map<String, dynamic>;
189 final paths = (body['paths'] as List?)?.cast<Map<String, dynamic>>() ?? const [];
190 if (paths.isEmpty) return null;
191 final p = paths.first;
192 return _PathInfo(
193 supportsExactOut: p['supportsExactAmountOut'] == true,
194 minToAmountHuman: (p['amountLimits']?['minAmount'] as String?) ?? '0',
195 );
196 }
197
198 @override
199 Future<double> fetchRate(
200 {required CryptoCurrency from,
201 required CryptoCurrency to,
202 required double amount,
203 required bool isFixedRateMode,
204 required bool isReceiveAmount}) async {
205 try {
206 if (_notSupportedAsSourceToken.contains(from) || _notSupportedAsSourceToken.contains(to)) {
207 printV('fetchRate: source token ${from.title} is not supported as source token');
208 return 0.0;
209 }
210
211 final chains = await _geSupportedChain();
212 if (chains.isEmpty) return 0.0;
213
214 final srcChain = _findChainByCurrency(from, chains);
215 final dstChain = _findChainByCurrency(to, chains);
216
217 await _ensureTokensCached(fromChain: srcChain, toChain: dstChain, from: from, to: to);
218
219 final srcToken = _getTokenAddress(currency: from, chain: srcChain);
220 final dstToken = _getTokenAddress(currency: to, chain: dstChain);
221
222 if (isReceiveAmount) {
223 final path = await _pickPath(
224 srcChainId: srcChain.chainId,
225 srcToken: srcToken,
226 dstChainId: dstChain.chainId,
227 dstToken: dstToken,
228 );
229 if (path == null || !path.supportsExactOut) {
230 printV(
231 'fetchRate: route does not support exact-amount-out for ${from.title} -> ${to.title}');
232 return 0.0;
233 }
234 }
235
236 final humanAmountStr = amount.toString();
237 final formattedAmount = AmountConverter.toBaseUnits(
238 humanAmountStr,
239 isReceiveAmount ? to.decimals : from.decimals,
240 );
241
242 final params = {
243 'swapDirection': isReceiveAmount ? 'exact-amount-out' : 'exact-amount-in',
244 'srcToken': srcToken,
245 'dstToken': dstToken,
246 'srcChainId': '${srcChain.chainId}',
247 'dstChainId': '${dstChain.chainId}',
248 'amount': formattedAmount,
249 };
250
251 final uri = Uri.https(_baseUrl, _getQuotePaths, params);
252 final response = await ProxyWrapper().get(clearnetUri: uri, headers: _headers);
253
254 if (response.statusCode != 200) {
255 printV('fetchRate failed: ${response.body}');
256 return 0.0;
257 }
258
259 final data = json.decode(response.body) as Map<String, dynamic>;
260 final exchangeRate = (data['exchangeRate'] as num?)?.toDouble() ?? 0.0;
261 return exchangeRate;
262 } catch (e) {
263 printV('fetchRate error: $e');
264 return 0.0;
265 }
266 }
267
268 @override
269 Future<Trade> createTrade({
270 required TradeRequest request,
271 required bool isFixedRateMode,
272 required bool isSendAll,
273 }) async {
274 try {
275 final sender = request.refundAddress.trim();
276 final recipient = request.toAddress.trim();
277 if (sender.isEmpty || recipient.isEmpty) {
278 throw Exception('Sender (refundAddress) or recipient (toAddress) is empty');
279 }
280
281 final chains = await _geSupportedChain();
282 if (chains.isEmpty) throw Exception('Failed to fetch supported chains');
283 final srcChain = _findChainByCurrency(request.fromCurrency, chains);
284 final dstChain = _findChainByCurrency(request.toCurrency, chains);
285
286 await _ensureTokensCached(
287 fromChain: srcChain,
288 toChain: dstChain,
289 from: request.fromCurrency,
290 to: request.toCurrency,
291 );
292
293 final srcToken = _getTokenAddress(currency: request.fromCurrency, chain: srcChain);
294 final dstToken = _getTokenAddress(currency: request.toCurrency, chain: dstChain);
295
296 final amountStr = isFixedRateMode ? request.toAmount : request.fromAmount;
297 final rawAmount = double.tryParse(amountStr) ?? 0.0;
298 if (rawAmount <= 0) throw Exception('Invalid amount');
299
300 final formattedAmount = AmountConverter.toBaseUnits(
301 amountStr,
302 isFixedRateMode ? request.toCurrency.decimals : request.fromCurrency.decimals,
303 );
304
305 final params = {
306 'actionType': 'swap-action',
307 'sender': sender,
308 'srcChainId': '${srcChain.chainId}',
309 'srcToken': srcToken,
310 'dstChainId': '${dstChain.chainId}',
311 'dstToken': dstToken,
312 'slippage': '300',
313 'swapDirection': isFixedRateMode ? 'exact-amount-out' : 'exact-amount-in',
314 'amount': formattedAmount,
315 'recipient': recipient,
316 };
317
318 final uri = Uri.https(_baseUrl, _getAction, params);
319 final res = await ProxyWrapper().get(clearnetUri: uri, headers: _headers);
320
321 if (res.statusCode != 200) {
322 throw Exception('getAction failed: ${res.statusCode} ${res.body}');
323 }
324
325 final data = json.decode(res.body) as Map<String, dynamic>;
326
327 final txId = data['txId'] as String? ?? '';
328
329 final vmId = data['vmId'] as String? ?? '';
330 final txObj = (data['tx'] as Map?) ?? const {};
331
332 final txTo = txObj['to']?.toString();
333 final chainId = txObj['chainId']?.toString();
334 final routerData = txObj['data']?.toString();
335
336 // Allow only:
337 // - null (native / deposit-address flow)
338 // - '0x' (no call data)
339 // - ERC20 transfer(0xa9059cbb) selector
340 // - swapAndExecute(0x9be111d1) selector
341 final isAllowed = routerData == null ||
342 routerData == '0x' ||
343 _decodeMethodSelector(routerData) == _transferSig ||
344 _decodeMethodSelector(routerData) == _swapAndExecuteSig;
345
346 if (!isAllowed) {
347 throw Exception('Does not support that method selector');
348 }
349
350 final txValue = txObj['value']?.toString() ?? '0';
351
352 final bridgeIds = (data['bridgeIds'] as List?) ?? const [];
353 if (txId.isEmpty) throw Exception('No txId returned by getAction');
354
355 final amtIn = (data['amountIn'] as Map?) ?? const {};
356 final amtInMax = (data['amountInMax'] as Map?) ?? const {};
357 final srcTokenAddr = amtIn['address']?.toString();
358 final srcTokenDecs = (amtIn['decimals'] as num?)?.toInt() ?? request.fromCurrency.decimals;
359 final requiresTokenApproval = data['requiresTokenApproval'] as bool? ?? false;
360
361 final reqAmountStr = (amtInMax['amount'] ?? amtIn['amount'])?.toString() ?? '0';
362 final reqAmountRaw = reqAmountStr.replaceAll('n', '');
363
364 final needToRegisterInSwapXyz = vmId == 'alt-vm' ||
365 bridgeIds.contains('alt-vm') ||
366 chainId == 'solana' ||
367 bridgeIds.contains('solana');
368
369 final trade = Trade(
370 id: txId,
371 router: chainId,
372 providerId: vmId,
373 from: request.fromCurrency,
374 to: request.toCurrency,
375 provider: description,
376 inputAddress: txTo,
377 refundAddress: request.refundAddress,
378 state: TradeState.created,
379 providerName: title,
380 createdAt: DateTime.now(),
381 amount: request.fromAmount,
382 receiveAmount: request.toAmount,
383 payoutAddress: request.toAddress,
384 isSendAll: isSendAll,
385 needToRegisterInSwapXyz: needToRegisterInSwapXyz,
386 sourceTokenAddress: srcTokenAddr ?? srcToken,
387 sourceTokenDecimals: srcTokenDecs,
388 sourceTokenAmountRaw: reqAmountRaw,
389 requiresTokenApproval: requiresTokenApproval,
390 routerData: routerData,
391 routerValue: txValue,
392 );
393
394 return trade;
395 } catch (e) {
396 printV('createTrade error: $e');
397 throw TradeNotCreatedException(description, description: e.toString());
398 }
399 }
400
401 /// Register a broadcasted tx with Swaps.xyz (required for alt-vm).
402 static Future<bool> registerAltVmTx({
403 required String txId,
404 required String txHash,
405 required int chainId,
406 required String vmId,
407 }) async {
408 try {
409 final uri = Uri.https(_baseUrl, _registerTxs);
410 final payload = {
411 'txId': txId,
412 'vmId': vmId,
413 'txHash': txHash,
414 'chainId': chainId,
415 };
416
417 final res = await ProxyWrapper().post(
418 clearnetUri: uri,
419 headers: {
420 ..._headers,
421 'content-type': 'application/json',
422 },
423 body: jsonEncode(payload),
424 );
425
426 if (res.statusCode != 200) {
427 printV('registerTxs failed: ${res.statusCode} ${res.body}');
428 return false;
429 }
430 final List<dynamic> body = json.decode(res.body) as List<dynamic>;
431 if (body.isEmpty) return false;
432
433 final isSuccess = (body[0] as Map<String, dynamic>)['success'] as bool? ?? false;
434
435 return isSuccess;
436 } catch (e) {
437 printV('registerAltVmTx error: $e');
438 return false;
439 }
440 }
441
442 @override
443 Future<Trade> findTradeById({required String id}) async {
444 if (id.isEmpty) {
445 throw Exception('Trade id is empty');
446 }
447
448 final uri = Uri.https(_baseUrl, _getStatus, {'txId': id});
449 final resp = await ProxyWrapper().get(clearnetUri: uri, headers: _headers);
450
451 if (resp.statusCode != 200) {
452 throw Exception('getStatus failed: ${resp.statusCode} ${resp.body}');
453 }
454
455 final data = json.decode(resp.body) as Map<String, dynamic>;
456 final isSuccess = (data['success'] as bool?);
457
458 if (isSuccess != null && !isSuccess) {
459 final error = data['error'] as Map<String, dynamic>?;
460 if (error != null) {
461 final code = error['code']?.toString() ?? 'unknown';
462 throw Exception('SwapXyzExchangeProvider findTradeById error: ($id) $code');
463 }
464 }
465
466 final statusStr = (data['status'] as String?)?.toLowerCase() ?? 'NOT_FOUND';
467 final state = _mapSwapsStatusToTradeState(statusStr);
468
469 final refundAddress = data['sender']?.toString();
470
471 final srcTransaction = (data['srcTx'] as Map?)?.cast<String, dynamic>();
472 final dstTransaction = (data['dstTx'] as Map?)?.cast<String, dynamic>();
473
474 final inputAddress = srcTransaction?['toAddress']?.toString();
475
476 final payoutAddress = dstTransaction?['toAddress']?.toString();
477
478 final srcPaymentToken = (srcTransaction?['paymentToken'] as Map?)?.cast<String, dynamic>();
479 final dstPaymentToken = (dstTransaction?['paymentToken'] as Map?)?.cast<String, dynamic>();
480
481 final fromSymbol = (srcPaymentToken?['symbol'] as String?) ?? '';
482 final toSymbol = (dstPaymentToken?['symbol'] as String?) ?? '';
483
484 CryptoCurrency? toCurrency;
485 if (toSymbol.isNotEmpty) {
486 toCurrency = CryptoCurrency.safeParseCurrencyFromString(toSymbol);
487 }
488
489 final srcDecimals = (srcPaymentToken?['decimals'] as num?)?.toInt() ?? 0;
490 final dstDecimals = (dstPaymentToken?['decimals'] as num?)?.toInt() ?? 0;
491
492 final txHash = srcTransaction?['txHash'] as String?;
493
494 // Minimal-unit amounts like "12000n"
495 final srcAmountRaw = srcPaymentToken?['amount']?.toString();
496 final dstAmountRaw = dstPaymentToken?['amount']?.toString();
497 String? receiveAmount;
498 if (dstAmountRaw != null) {
499 final dstAmountMinimal = _stripN(dstAmountRaw);
500 receiveAmount = AmountConverter.fromBaseUnits(dstAmountMinimal, dstDecimals);
501 }
502
503 final srcAmountMinimal = _stripN(srcAmountRaw);
504 final amount = AmountConverter.fromBaseUnits(srcAmountMinimal, srcDecimals);
505 final fromCurrency = CryptoCurrency.safeParseCurrencyFromString(fromSymbol);
506
507 // Timestamps can be num or "123n" handle both
508 final srcTs = _parseUnixSeconds(srcTransaction?['timestamp']);
509 final dstTs = _parseUnixSeconds(dstTransaction?['timestamp']);
510 final timestamp = srcTs ?? dstTs;
511
512 final createdAt = timestamp != null
513 ? DateTime.fromMillisecondsSinceEpoch(timestamp * 1000, isUtc: true).toLocal()
514 : null;
515
516 return Trade(
517 id: (data['txId'] as String?) ?? id,
518 from: fromCurrency,
519 to: toCurrency,
520 provider: description,
521 inputAddress: inputAddress,
522 payoutAddress: payoutAddress,
523 amount: amount,
524 receiveAmount: receiveAmount,
525 txId: txHash,
526 state: state,
527 createdAt: createdAt,
528 refundAddress: refundAddress,
529 );
530 }
531
532 TradeState _mapSwapsStatusToTradeState(String s) {
533 switch (s) {
534 case 'pending':
535 case 'processing':
536 return TradeState.pending;
537 case 'success':
538 case 'completed':
539 case 'complete':
540 return TradeState.finished;
541 case 'failed':
542 case 'cancelled':
543 case 'canceled':
544 case 'error':
545 return TradeState.failed;
546 default:
547 return TradeState.pending;
548 }
549 }
550
551 // Load & cache supported chains once
552 Future<List<Chain>> _geSupportedChain() async {
553 if (_supportedChainList.isNotEmpty) return _supportedChainList;
554 try {
555 final uri = Uri.https(_baseUrl, _getChainList);
556 final response = await ProxyWrapper().get(clearnetUri: uri, headers: _headers);
557 if (response.statusCode != 200) return [];
558
559 final data = json.decode(response.body) as List<dynamic>;
560 _supportedChainList
561 ..clear()
562 ..addAll(data.map((e) => Chain.fromJson(e as Map<String, dynamic>)));
563 return _supportedChainList;
564 } catch (e) {
565 printV(e);
566 return [];
567 }
568 }
569
570 Future<void> _ensureTokensCached({
571 required Chain fromChain,
572 required Chain toChain,
573 required CryptoCurrency from,
574 required CryptoCurrency to,
575 }) async {
576 final needSrc = !_tokensCache.containsKey(fromChain.chainId) ||
577 (_tokensCache[fromChain.chainId]?.isEmpty ?? true);
578
579 final needDst = !_tokensCache.containsKey(toChain.chainId) ||
580 (_tokensCache[toChain.chainId]?.isEmpty ?? true);
581
582 if (!needSrc && !needDst) return;
583
584 if (needSrc) {
585 await _fetchAndCacheTokens(srcChainId: fromChain.chainId);
586 }
587 if (needDst) {
588 await _fetchAndCacheTokens(srcChainId: toChain.chainId);
589 }
590 }
591
592 // call getPaths and merge tokens into cache keyed by the chainId
593 Future<void> _fetchAndCacheTokens({
594 required int srcChainId,
595 }) async {
596 final params = <String, String>{
597 'srcChainId': '$srcChainId',
598 'srcToken': '0x0000000000000000000000000000000000000000',
599 // Native placeholder
600 };
601
602 final uri = Uri.https(_baseUrl, _getPaths, params);
603 final res = await ProxyWrapper().get(clearnetUri: uri, headers: _headers);
604 if (res.statusCode != 200) {
605 printV('getPaths failed: ${res.statusCode} ${res.body}');
606 return;
607 }
608
609 Map<String, dynamic> body;
610 try {
611 body = json.decode(res.body) as Map<String, dynamic>;
612 } catch (e) {
613 printV('getPaths JSON decode error: $e');
614 return;
615 }
616
617 // Always cache the source chain's native token (from body['srcToken'])
618 final srcTokenJson = body['srcToken'] as Map<String, dynamic>?;
619 if (srcTokenJson != null) {
620 final symbol = (srcTokenJson['symbol'] as String? ?? '').toUpperCase();
621 if (symbol.isNotEmpty) {
622 final isNative = srcTokenJson['isNative'] == true;
623 final decimals = (srcTokenJson['decimals'] as num?)?.toInt();
624 // Treat native token as address = null so _getTokenAddress() emits zero-address
625 final addr = isNative ? null : (srcTokenJson['address'] as String?);
626 _mergeCache(srcChainId, [
627 TokenPathInfo(
628 symbol: symbol,
629 address: addr,
630 decimals: decimals,
631 minAmount: srcTokenJson['minAmount']?.toString(),
632 maxAmount: srcTokenJson['maxAmount']?.toString(),
633 ),
634 ]);
635 }
636 }
637
638 final paths = (body['paths'] as List?) ?? const [];
639 if (paths.isEmpty) return;
640
641 for (final path in paths) {
642 final map = path as Map<String, dynamic>;
643 final pathChainId = (map['chainId'] as num?)?.toInt();
644 if (pathChainId == null) continue;
645
646 final tokensField = map['tokens'];
647
648 // Case 1: String "all" -> cache empty list to indicate all tokens supported
649 if (tokensField is String) {
650 if (tokensField.toLowerCase() == 'all') {
651 _tokensCache[pathChainId] = _tokensCache[pathChainId] ?? <TokenPathInfo>[];
652 }
653 continue;
654 }
655
656 // Case 2: List -> parse and merge
657 if (tokensField is List) {
658 final parsed = <TokenPathInfo>[];
659 for (final token in tokensField) {
660 if (token is Map<String, dynamic>) {
661 try {
662 parsed.add(TokenPathInfo.fromJson(token));
663 } catch (e) {
664 printV('Token parse error on chain $pathChainId: $e : $token');
665 }
666 }
667 }
668 if (parsed.isNotEmpty) {
669 _mergeCache(pathChainId, parsed);
670 }
671 }
672 }
673 }
674
675 // Merge by symbol, prefer entries that have a non-empty address/decimals
676 void _mergeCache(int chainId, List<TokenPathInfo> incoming) {
677 final existing = _tokensCache[chainId] ?? const <TokenPathInfo>[];
678 final bySymbol = <String, TokenPathInfo>{for (final t in existing) t.symbol: t};
679
680 for (final t in incoming) {
681 final cur = bySymbol[t.symbol];
682 if (cur == null) {
683 bySymbol[t.symbol] = t;
684 } else {
685 // If incoming has a real address/decimals, prefer it
686 final hasBetterAddr = (t.address != null && t.address!.isNotEmpty) &&
687 (cur.address == null || cur.address!.isEmpty);
688 final hasBetterDec = (t.decimals != null) && (cur.decimals == null);
689 if (hasBetterAddr || hasBetterDec) {
690 bySymbol[t.symbol] = t;
691 }
692 }
693 }
694
695 _tokensCache[chainId] = bySymbol.values.toList();
696 }
697
698 String _normalizeCakeNativeTokenName(String title) {
699 final name = title.toUpperCase();
700 return switch (name) {
701 'ZZEC' => 'ZEC',
702 _ => name,
703 };
704 }
705
706 String _getTokenAddress({
707 required CryptoCurrency currency,
708 required Chain chain,
709 }) {
710 final symbol = _normalizeCakeNativeTokenName(currency.title);
711 final list = _tokensCache[chain.chainId];
712
713 // Try cache hit
714 if (list != null && list.isNotEmpty) {
715 for (final t in list) {
716 if (t.symbol == symbol && t.address != null && t.address!.isNotEmpty) {
717 return t.address!;
718 } else if (t.symbol == symbol && (t.address == null)) {
719 // Native token on this chain
720 return '0x0000000000000000000000000000000000000000';
721 }
722 }
723 }
724
725 // May fail for non-native Alt-VM assets
726 return symbol;
727 }
728
729 Map<String, dynamic>? findTokenBySymbol({required String title, required List<dynamic> tokens}) {
730 final reqSymbol = title.toUpperCase();
731 for (final token in tokens) {
732 final map = token as Map<String, dynamic>;
733 final symbol = (map['symbol'] as String?)?.toUpperCase();
734 if (symbol == reqSymbol) return map;
735 }
736 return null;
737 }
738
739 Chain _findChainByCurrency(CryptoCurrency cur, List<Chain> chains) {
740 final network = _normalizeCakeNetwork(cur.tag ?? cur.title);
741 return chains.firstWhere(
742 (c) {
743 return c.name.toUpperCase() == network;
744 },
745 orElse: () => throw Exception('Unsupported chain for ${cur.title}'),
746 );
747 }
748
749 String _normalizeCakeNetwork(String network) {
750 return switch (network.toUpperCase()) {
751 'ETH' => 'ETHEREUM',
752 'BSC' => 'BNB SMART CHAIN',
753 'POL' => 'POLYGON',
754 'AVAXC' => 'AVALANCHE',
755 'TRX' => 'TRON',
756 'SOL' => 'SOLANA',
757 'CRO' => 'CRONOS',
758 'ADA' => 'CARDANO',
759 'KAS' => 'KASPA',
760 'TON' => 'TONCOIN',
761 'BCH' => 'BITCOIN CASH',
762 'ARB' => 'ARBITRUM',
763 _ => network.toUpperCase(),
764 };
765 }
766
767 int? _parseUnixSeconds(dynamic value) {
768 if (value == null) return null;
769 if (value is num) return value.toInt();
770 if (value is String) {
771 final clean = _stripN(value);
772 return int.tryParse(clean);
773 }
774 return null;
775 }
776
777 String _stripN(String? str) {
778 final s = str ?? '0';
779 return s.endsWith('n') ? s.substring(0, s.length - 1) : s;
780 }
781
782 String _decodeMethodSelector(String s) =>
783 (s.startsWith('0x') && s.length >= 10) ? s.substring(0, 10) : '';
784 }
785
786 class TokenPathInfo {
787 final String symbol;
788 final String? address;
789 final int? decimals;
790 final String? minAmount;
791 final String? maxAmount;
792
793 TokenPathInfo({
794 required this.symbol,
795 required this.address,
796 required this.decimals,
797 required this.minAmount,
798 required this.maxAmount,
799 });
800
801 factory TokenPathInfo.fromJson(Map<String, dynamic> json) => TokenPathInfo(
802 symbol: (json['symbol'] as String?)?.toUpperCase() ?? '',
803 address: json['address'] as String?,
804 decimals: json['decimals'] as int?,
805 minAmount: json['minAmount']?.toString(),
806 maxAmount: json['maxAmount']?.toString(),
807 );
808 }
809
810 class Chain {
811 final int chainId;
812 final String name;
813 final String vmId;
814
815 Chain({
816 required this.chainId,
817 required this.name,
818 required this.vmId,
819 });
820
821 factory Chain.fromJson(Map<String, dynamic> json) {
822 return Chain(
823 chainId: json['chainId'] as int,
824 name: json['name'] as String,
825 vmId: json['vmId'] as String,
826 );
827 }
828 }
829
830 class _PathInfo {
831 final bool supportsExactOut;
832 final String minToAmountHuman;
833
834 _PathInfo({required this.supportsExactOut, required this.minToAmountHuman});
835 }