CW-1228: Automatically detect wallet tokens for EVM chains (#2827)

* feat: Automatically detect wallet tokens for EVM chains * feat: Disable potential tg scam tokens by default * feat: Add homoglyph normalization to detect spoofing attacks in token symbols * refactor: Improve scam token detection and apply to automatically fetched EVM tokens * refactor: Enhance scam detection for automatically detected tokens in evm wallets * feat: Add fiat check to scam checks on automatically imported token and disable tokens that fail the check * feat: Add fiat check to scam checks on automatically imported token and disable tokens that fail the check * feat: Add fiat check to scam checks on automatically imported token and disable tokens that fail the check * feat: Enable whitelisted tokens with balance when importing wallet tokens

David Adegoke committed Feb 5, 2026 at 13:53 UTC 4f8cccce4fd65b22c8139e17f076f86ea0f38f0a
8 files changed +419 -73
cw_core/lib/utils/homoglyph_normalizer.dart new
+68
@@ -0,0 +1,68 @@
1 +/// Normalizes homoglyph characters (Cyrillic, Greek, etc.) to their ASCII equivalents
2 +/// to detect spoofing attacks like "UЅDС" (Cyrillic) vs "USDC" (ASCII)
3 +String normalizeHomoglyphs(String text) {
4 + final homoglyphMap = {
5 + // Cyrillic letters that look like Latin
6 + 'А': 'A', // Cyrillic A
7 + 'В': 'B', // Cyrillic Ve
8 + 'Е': 'E', // Cyrillic Ie
9 + 'К': 'K', // Cyrillic Ka
10 + 'М': 'M', // Cyrillic Em
11 + 'Н': 'H', // Cyrillic En
12 + 'О': 'O', // Cyrillic O
13 + 'Р': 'P', // Cyrillic Er
14 + 'С': 'C', // Cyrillic Es
15 + 'Т': 'T', // Cyrillic Te
16 + 'У': 'Y', // Cyrillic U
17 + 'Х': 'X', // Cyrillic Kha
18 + 'а': 'a',
19 + 'в': 'b',
20 + 'е': 'e',
21 + 'к': 'k',
22 + 'м': 'm',
23 + 'н': 'h',
24 + 'о': 'o',
25 + 'р': 'p',
26 + 'с': 'c',
27 + 'т': 't',
28 + 'у': 'y',
29 + 'х': 'x',
30 + 'Ѕ': 'S', // Cyrillic Dze (looks like S)
31 + 'ѕ': 's',
32 + 'І': 'I', // Cyrillic I
33 + 'і': 'i',
34 + 'Ј': 'J', // Cyrillic Je
35 + 'ј': 'j',
36 + // Greek letters that look like Latin
37 + 'Α': 'A', // Alpha
38 + 'Β': 'B', // Beta
39 + 'Ε': 'E', // Epsilon
40 + 'Ζ': 'Z', // Zeta
41 + 'Η': 'H', // Eta
42 + 'Ι': 'I', // Iota
43 + 'Κ': 'K', // Kappa
44 + 'Μ': 'M', // Mu
45 + 'Ν': 'N', // Nu
46 + 'Ο': 'O', // Omicron
47 + 'Ρ': 'P', // Rho
48 + 'Τ': 'T', // Tau
49 + 'Υ': 'Y', // Upsilon
50 + 'Χ': 'X', // Chi
51 + 'α': 'a',
52 + 'β': 'b',
53 + 'ε': 'e',
54 + 'ζ': 'z',
55 + 'η': 'h',
56 + 'ι': 'i',
57 + 'κ': 'k',
58 + 'μ': 'm',
59 + 'ν': 'n',
60 + 'ο': 'o',
61 + 'ρ': 'p',
62 + 'τ': 't',
63 + 'υ': 'y',
64 + 'χ': 'x',
65 + };
66 +
67 + return text.split('').map((char) => homoglyphMap[char] ?? char).join('');
68 +}
cw_evm/lib/clients/evm_chain_client.dart
+113
@@ -518,6 +518,10 @@ class EVMChainClient {
518 }
519
520 Future<Erc20Token?> getErc20TokenFromMoralis(String contractAddress, String chainName) async {
521 + if (secrets.moralisApiKey.isEmpty) {
522 + printV('Moralis API key is empty, cannot fetch token info');
523 + return null;
524 + }
525 final uri = Uri.https(
526 'deep-index.moralis.io',
527 '/api/v2.2/erc20/metadata',
@@ -567,6 +571,93 @@ class EVMChainClient {
571 );
572 }
573
574 + Future<List<MoralisWalletTokenBalance>> fetchWalletTokensFromMoralis(
575 + String address,
576 + String chainName,
577 + ) async {
578 + try {
579 + if (secrets.moralisApiKey.isEmpty) {
580 + printV('Moralis API key is empty, cannot fetch wallet tokens');
581 + return [];
582 + }
583 +
584 + final uri = Uri.https(
585 + 'deep-index.moralis.io',
586 + '/api/v2.2/$address/erc20',
587 + {
588 + "chain": chainName,
589 + },
590 + );
591 +
592 + final response = await client.get(
593 + uri,
594 + headers: {
595 + "Accept": "application/json",
596 + "X-API-Key": secrets.moralisApiKey,
597 + },
598 + );
599 +
600 + if (response.statusCode < 200 || response.statusCode >= 300) {
601 + printV('Moralis API returned invalid status code: ${response.statusCode}');
602 + return [];
603 + }
604 +
605 + final decodedResponse = jsonDecode(response.body) as List;
606 +
607 + final List<MoralisWalletTokenBalance> tokens = [];
608 +
609 + for (final item in decodedResponse) {
610 + final tokenData = item as Map<String, dynamic>;
611 +
612 + final balanceStr = tokenData['balance'] as String? ?? '0';
613 + final balanceWei = BigInt.tryParse(balanceStr) ?? BigInt.zero;
614 + if (balanceWei == BigInt.zero) continue;
615 +
616 + final contractAddress = (tokenData['token_address'] as String? ?? '').toLowerCase();
617 + final name = (tokenData['name'] as String? ?? '').toString();
618 + final symbol = (tokenData['symbol'] as String? ?? '').toString();
619 + final symbolFiltered = symbol.replaceFirst(RegExp('^\\\$'), '');
620 +
621 + final decimalsRaw = tokenData['decimals'];
622 + final decimals =
623 + decimalsRaw is int ? decimalsRaw : int.tryParse(decimalsRaw.toString()) ?? 18;
624 +
625 + final logo = tokenData['logo'] as String?;
626 + final thumbnail = tokenData['thumbnail'] as String?;
627 + final iconUrl = logo ?? thumbnail;
628 +
629 + final possibleSpamRaw = tokenData['possible_spam'];
630 + final possibleSpam = possibleSpamRaw is bool
631 + ? possibleSpamRaw
632 + : (possibleSpamRaw.toString().toLowerCase() == 'true');
633 +
634 + final verifiedContractRaw = tokenData['verified_contract'];
635 + final verifiedContract = verifiedContractRaw is bool
636 + ? verifiedContractRaw
637 + : (verifiedContractRaw.toString().toLowerCase() == 'true');
638 +
639 + tokens.add(
640 + MoralisWalletTokenBalance(
641 + contractAddress: contractAddress,
642 + name: name,
643 + symbol: symbolFiltered,
644 + decimals: decimals,
645 + iconUrl: iconUrl,
646 + balanceWei: balanceWei,
647 + possibleSpam: possibleSpam,
648 + verifiedContract: verifiedContract,
649 + ),
650 + );
651 + }
652 +
653 + return tokens;
654 + } catch (e, stackTrace) {
655 + printV('Error fetching wallet tokens from Moralis: ${e.toString()}');
656 + printV('Stack trace: ${stackTrace.toString()}');
657 + return [];
658 + }
659 + }
660 +
661 Uint8List hexToBytes(String hexString) {
662 return Uint8List.fromList(
663 hex.HEX.decode(hexString.startsWith('0x') ? hexString.substring(2) : hexString));
@@ -599,3 +690,25 @@ class EVMChainClient {
690 // return exponent;
691 // }
692 }
693 +
694 +class MoralisWalletTokenBalance {
695 + final String contractAddress;
696 + final String name;
697 + final String symbol;
698 + final int decimals;
699 + final String? iconUrl;
700 + final BigInt balanceWei;
701 + final bool possibleSpam;
702 + final bool verifiedContract;
703 +
704 + MoralisWalletTokenBalance({
705 + required this.contractAddress,
706 + required this.name,
707 + required this.symbol,
708 + required this.decimals,
709 + this.iconUrl,
710 + required this.balanceWei,
711 + required this.possibleSpam,
712 + required this.verifiedContract,
713 + });
714 +}
cw_evm/lib/evm_chain_wallet.dart
+94 -12
@@ -15,6 +15,7 @@ import 'package:cw_core/pending_transaction.dart';
15 import 'package:cw_core/sync_status.dart';
16 import 'package:cw_core/transaction_direction.dart';
17 import 'package:cw_core/transaction_priority.dart';
18 +import 'package:cw_core/utils/homoglyph_normalizer.dart';
19 import 'package:cw_core/utils/print_verbose.dart';
20 import 'package:cw_core/wallet_addresses.dart';
21 import 'package:cw_core/wallet_base.dart';
@@ -440,24 +441,50 @@ abstract class EVMChainWalletBase
441 await save();
442 }
443
443 - Future<void> _checkForExistingScamTokens() async {
444 + bool isTokenPropertiesSuspicious(Erc20Token token) {
445 final baseCurrencySymbols = CryptoCurrency.all.map((e) => e.title.toUpperCase()).toList();
446
446 - for (var token in erc20Currencies) {
447 - bool isPotentialScam = false;
447 + bool isTokenWhitelisted = getDefaultTokenContractAddresses
448 + .any((element) => element.toLowerCase() == token.contractAddress.toLowerCase());
449 +
450 + // Normalize the token data to check for homoglyph spoofing attack, characters that look like ASCII (Cyrillic, Greek, etc.)
451 + final normalizedName = normalizeHomoglyphs(token.name.trim().toUpperCase());
452 + final normalizedSymbol = normalizeHomoglyphs(token.symbol.trim().toUpperCase());
453 + final normalizedTitle = normalizeHomoglyphs(token.title.trim().toUpperCase());
454 +
455 + final suspiciousStrings = [
456 + 't.me',
457 + '.me',
458 + 'telegram',
459 + 'http',
460 + 'https',
461 + '.com',
462 + 'airdrop',
463 + 'www',
464 + '.xyz',
465 + '🎁',
466 + ];
467 +
468 + final hasSuspiciousData = suspiciousStrings.any(
469 + (element) =>
470 + normalizedName.toLowerCase().contains(element) ||
471 + normalizedSymbol.toLowerCase().contains(element) ||
472 + normalizedTitle.toLowerCase().contains(element),
473 + );
474
449 - bool isWhitelisted = getDefaultTokenContractAddresses
450 - .any((element) => element.toLowerCase() == token.contractAddress.toLowerCase());
475 + // Check if the token symbol is the same as any of the base currencies symbols (ETH, SOL, POL, TRX, etc).
476 + // If it is, then it's probably a scam unless it's in the whitelist.
477 + final hasSuspiciousSymbol = baseCurrencySymbols.contains(normalizedSymbol);
478
452 - final tokenSymbol = token.title.toUpperCase();
479 + return hasSuspiciousData || (hasSuspiciousSymbol && !isTokenWhitelisted);
480 + }
481
454 - // check if the token symbol is the same as any of the base currencies symbols (ETH, SOL, POL, TRX, etc):
455 - // if it is, then it's probably a scam unless it's in the whitelist
456 - if (baseCurrencySymbols.contains(tokenSymbol.trim().toUpperCase()) && !isWhitelisted) {
457 - isPotentialScam = true;
458 - }
482 + Future<void> _checkForExistingScamTokens() async {
483 + for (var token in erc20Currencies) {
484 + bool isPotentialScam = false;
485
460 - if (isPotentialScam) {
486 + if (isTokenPropertiesSuspicious(token)) {
487 + isPotentialScam = true;
488 token.isPotentialScam = true;
489 token.iconPath = null;
490 await token.save();
@@ -482,6 +509,61 @@ abstract class EVMChainWalletBase
509 }
510 }
511
512 + Future<List<Erc20Token>> discoverTokensFromMoralis() async {
513 + try {
514 + if (!evmChainErc20TokensBox.isOpen) return [];
515 +
516 + final address = walletAddresses.address;
517 + if (address.isEmpty) return [];
518 +
519 + final chainName = EVMChainUtils.getDefaultTokenSymbol(selectedChainId).toLowerCase();
520 +
521 + final walletTokens = await _client.fetchWalletTokensFromMoralis(address, chainName);
522 + if (walletTokens.isEmpty) return [];
523 +
524 + final existingTokenAddresses = {
525 + for (final token in evmChainErc20TokensBox.values)
526 + token.contractAddress.toLowerCase(): token,
527 + };
528 +
529 + final whitelistedTokenAddresses =
530 + getDefaultTokenContractAddresses.map((a) => a.toLowerCase()).toSet();
531 +
532 + final List<Erc20Token> newTokens = [];
533 +
534 + for (final token in walletTokens) {
535 + final addr = token.contractAddress.toLowerCase();
536 +
537 + final existingToken = existingTokenAddresses[addr];
538 + if (existingToken != null) {
539 + if (whitelistedTokenAddresses.contains(addr) && !existingToken.enabled) {
540 + existingToken.enabled = true;
541 + await existingToken.save();
542 + await addErc20Token(existingToken);
543 + }
544 + continue;
545 + }
546 +
547 + final newToken = Erc20Token(
548 + name: token.name,
549 + symbol: token.symbol,
550 + contractAddress: addr,
551 + decimal: token.decimals,
552 + iconPath: token.iconUrl,
553 + tag: EVMChainUtils.getDefaultTokenTag(selectedChainId),
554 + isPotentialScam: token.possibleSpam,
555 + );
556 +
557 + newTokens.add(newToken);
558 + }
559 +
560 + return newTokens;
561 + } catch (e) {
562 + printV('Error discovering tokens from Moralis: ${e.toString()}');
563 + return [];
564 + }
565 + }
566 +
567 @override
568 int calculateEstimatedFee(TransactionPriority priority, int? amount) => 0;
569
lib/evm/cw_evm.dart
+52
@@ -513,4 +513,56 @@ class CWEVM extends EVM {
513
514 @override
515 bool hasPriorityFee(int chainId) => EVMChainUtils.hasPriorityFee(chainId);
516 +
517 + @override
518 + Future<bool> checkTokenFiatPrice(WalletBase wallet, Erc20Token token) async {
519 + try {
520 + final settingsStore = getIt.get<SettingsStore>();
521 + final fiatCurrency = settingsStore.fiatCurrency;
522 + final torOnly = settingsStore.fiatApiMode == FiatApiMode.torOnly;
523 +
524 + final price = await FiatConversionService.fetchPrice(
525 + crypto: token,
526 + fiat: fiatCurrency,
527 + torOnly: torOnly,
528 + );
529 +
530 + return price > 0;
531 + } catch (e) {
532 + return false;
533 + }
534 + }
535 +
536 + @override
537 + Future<void> discoverAndAddWalletTokens(WalletBase wallet) async {
538 + if (wallet is! EVMChainWallet) return;
539 +
540 + try {
541 + final discoveredTokens = await wallet.discoverTokensFromMoralis();
542 +
543 + if (discoveredTokens.isEmpty) return;
544 +
545 + final List<Future<void>> tokenChecks = [];
546 +
547 + for (final token in discoveredTokens) {
548 + tokenChecks.add((() async {
549 + final isPropertiesSuspicious = wallet.isTokenPropertiesSuspicious(token);
550 +
551 + bool hasValidFiatPrice = true;
552 + if (!isPropertiesSuspicious) {
553 + hasValidFiatPrice = await checkTokenFiatPrice(wallet, token);
554 + }
555 +
556 + final isSpam = isPropertiesSuspicious || !hasValidFiatPrice;
557 +
558 + token.isPotentialScam = isSpam;
559 + token.enabled = !isSpam;
560 +
561 + await wallet.addErc20Token(token);
562 + })());
563 + }
564 +
565 + await Future.wait(tokenChecks);
566 + } catch (_) {}
567 + }
568 }
lib/reactions/on_current_wallet_change.dart
+4
@@ -107,6 +107,10 @@ void startCurrentWalletChangeReaction(
107
108 await wallet.walletInfo.save();
109 }
110 +
111 + if (isEVMCompatibleChain(wallet.type)) {
112 + await evm!.discoverAndAddWalletTokens(wallet);
113 + }
114 } catch (e) {
115 printV(e.toString());
116 }
lib/src/screens/dashboard/pages/balance/balance_row_widget.dart
+66 -61
@@ -101,78 +101,83 @@ class BalanceRowWidget extends StatelessWidget {
101 mainAxisAlignment: MainAxisAlignment.spaceBetween,
102 crossAxisAlignment: CrossAxisAlignment.center,
103 children: [
104 - Column(
105 - crossAxisAlignment: CrossAxisAlignment.start,
106 - children: [
107 - GestureDetector(
108 - behavior: HitTestBehavior.opaque,
109 - onTap: hasAdditionalBalance
110 - ? () => _showBalanceDescription(
111 - context, S.of(context).available_balance_description)
112 - : null,
113 - child: Row(
114 - children: [
115 - Semantics(
116 - hint: 'Double tap to see more information',
117 - container: true,
118 - child: Text(
119 - '${availableBalanceLabel}',
120 - style: Theme.of(context).textTheme.bodySmall?.copyWith(
121 - color: Theme.of(context).colorScheme.onSurfaceVariant,
122 - height: 1,
123 - ),
124 - ),
125 - ),
126 - if (hasAdditionalBalance)
127 - Padding(
128 - padding: const EdgeInsets.symmetric(horizontal: 4),
129 - child: Icon(
130 - Icons.help_outline,
131 - size: 16,
132 - color: Theme.of(context).colorScheme.onSurfaceVariant,
104 + Expanded(
105 + child: Column(
106 + crossAxisAlignment: CrossAxisAlignment.start,
107 + children: [
108 + GestureDetector(
109 + behavior: HitTestBehavior.opaque,
110 + onTap: hasAdditionalBalance
111 + ? () => _showBalanceDescription(
112 + context, S.of(context).available_balance_description)
113 + : null,
114 + child: Row(
115 + children: [
116 + Semantics(
117 + hint: 'Double tap to see more information',
118 + container: true,
119 + child: Text(
120 + '${availableBalanceLabel}',
121 + style: Theme.of(context).textTheme.bodySmall?.copyWith(
122 + color: Theme.of(context).colorScheme.onSurfaceVariant,
123 + height: 1,
124 + ),
125 ),
126 ),
135 - ],
136 - ),
137 - ),
138 - SizedBox(height: 6),
139 - AutoSizeText(
140 - availableBalance,
141 - style: Theme.of(context).textTheme.titleLarge?.copyWith(
142 - color: Theme.of(context).colorScheme.onSurface,
143 - fontWeight: FontWeight.w900,
144 - fontSize: 24,
145 - height: 1,
146 - ),
147 - maxLines: 1,
148 - textAlign: TextAlign.start,
149 - ),
150 - SizedBox(height: 6),
151 - if (isTestnet)
152 - Text(
153 - S.of(context).testnet_coins_no_value,
154 - textAlign: TextAlign.center,
155 - style: Theme.of(context).textTheme.bodyMedium?.copyWith(
156 - height: 1,
157 - ),
127 + if (hasAdditionalBalance)
128 + Padding(
129 + padding: const EdgeInsets.symmetric(horizontal: 4),
130 + child: Icon(
131 + Icons.help_outline,
132 + size: 16,
133 + color: Theme.of(context).colorScheme.onSurfaceVariant,
134 + ),
135 + ),
136 + ],
137 + ),
138 ),
159 - if (!isTestnet)
160 - Text(
161 - '${availableFiatBalance}',
162 - textAlign: TextAlign.center,
163 - style: Theme.of(context).textTheme.bodyMedium?.copyWith(
164 - fontSize: 16,
165 - fontWeight: FontWeight.w500,
166 - color: Theme.of(context).colorScheme.onSurfaceVariant,
139 + SizedBox(height: 6),
140 + AutoSizeText(
141 + availableBalance,
142 + minFontSize: 16,
143 + overflow: TextOverflow.ellipsis,
144 + style: Theme.of(context).textTheme.titleLarge?.copyWith(
145 + color: Theme.of(context).colorScheme.onSurface,
146 + fontWeight: FontWeight.w900,
147 + fontSize: 24,
148 height: 1,
149 ),
150 + maxLines: 1,
151 + textAlign: TextAlign.start,
152 ),
170 - ],
153 + SizedBox(height: 6),
154 + if (isTestnet)
155 + Text(
156 + S.of(context).testnet_coins_no_value,
157 + textAlign: TextAlign.center,
158 + style: Theme.of(context).textTheme.bodyMedium?.copyWith(
159 + height: 1,
160 + ),
161 + ),
162 + if (!isTestnet)
163 + Text(
164 + '${availableFiatBalance}',
165 + textAlign: TextAlign.center,
166 + style: Theme.of(context).textTheme.bodyMedium?.copyWith(
167 + fontSize: 16,
168 + fontWeight: FontWeight.w500,
169 + color: Theme.of(context).colorScheme.onSurfaceVariant,
170 + height: 1,
171 + ),
172 + ),
173 + ],
174 + ),
175 ),
176 SizedBox(
177 //width: min(MediaQuery.of(context).size.width * 0.2, 100),
178 child: Center(
179 child: Column(
180 + crossAxisAlignment: CrossAxisAlignment.end,
181 children: [
182 CakeImageWidget(
183 imageUrl: currency.iconPath,
lib/view_model/dashboard/balance_view_model.dart
+15
@@ -7,6 +7,7 @@ import 'package:cw_core/transaction_history.dart';
7 import 'package:cw_core/wallet_base.dart';
8 import 'package:cw_core/balance.dart';
9 import 'package:cw_core/crypto_currency.dart';
10 +import 'package:cw_core/erc20_token.dart';
11 import 'package:cw_core/transaction_info.dart';
12 import 'package:cw_core/wallet_type.dart';
13 import 'package:cake_wallet/generated/i18n.dart';
@@ -378,6 +379,20 @@ abstract class BalanceViewModelBase with Store {
379 if (a.asset == wallet.currency) return -1;
380 }
381
382 + if (isEVMCompatibleChain(wallet.type)) {
383 + final aIsToken = a.asset is Erc20Token;
384 + final bIsToken = b.asset is Erc20Token;
385 +
386 + final aHasBalance = (double.tryParse(a.availableBalance) ?? 0) > 0;
387 + final bHasBalance = (double.tryParse(b.availableBalance) ?? 0) > 0;
388 +
389 + // Adding this so tokens with balance come before tokens without balance
390 + if (aIsToken && bIsToken) {
391 + if (aHasBalance && !bHasBalance) return -1;
392 + if (!aHasBalance && bHasBalance) return 1;
393 + }
394 + }
395 +
396 switch (sortBalanceBy) {
397 case SortBalanceBy.FiatBalance:
398 final aFiatBalance = _getFiatBalance(
tool/configure.dart
+7
@@ -1345,6 +1345,10 @@ import 'package:web3dart/web3dart.dart';
1345
1346 """;
1347 const evmCWHeaders = """
1348 +import 'package:cake_wallet/core/fiat_conversion_service.dart';
1349 +import 'package:cake_wallet/di.dart';
1350 +import 'package:cake_wallet/entities/fiat_api_mode.dart';
1351 +import 'package:cake_wallet/store/settings_store.dart';
1352 import 'package:cw_evm/utils/evm_chain_formatter.dart';
1353 import 'package:cw_evm/evm_chain_mnemonics.dart';
1354 import 'package:cw_evm/evm_chain_registry.dart';
@@ -1518,6 +1522,9 @@ abstract class EVM {
1522 String? getExplorerUrlForChainId(int chainId, {bool showProtocol = true});
1523
1524 bool hasPriorityFee(int chainId);
1525 +
1526 + Future<bool> checkTokenFiatPrice(WalletBase wallet, Erc20Token token);
1527 + Future<void> discoverAndAddWalletTokens(WalletBase wallet);
1528 }
1529
1530 class ChainInfo {