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
This commit is contained in:
David Adegoke
2026-02-05 13:53:39 +01:00
committed by GitHub
parent 308de0389d
commit 4f8cccce4f
8 changed files with 419 additions and 73 deletions
@@ -0,0 +1,68 @@
/// Normalizes homoglyph characters (Cyrillic, Greek, etc.) to their ASCII equivalents
/// to detect spoofing attacks like "UЅDС" (Cyrillic) vs "USDC" (ASCII)
String normalizeHomoglyphs(String text) {
final homoglyphMap = {
// Cyrillic letters that look like Latin
'А': 'A', // Cyrillic A
'В': 'B', // Cyrillic Ve
'Е': 'E', // Cyrillic Ie
'К': 'K', // Cyrillic Ka
'М': 'M', // Cyrillic Em
'Н': 'H', // Cyrillic En
'О': 'O', // Cyrillic O
'Р': 'P', // Cyrillic Er
'С': 'C', // Cyrillic Es
'Т': 'T', // Cyrillic Te
'У': 'Y', // Cyrillic U
'Х': 'X', // Cyrillic Kha
'а': 'a',
'в': 'b',
'е': 'e',
'к': 'k',
'м': 'm',
'н': 'h',
'о': 'o',
'р': 'p',
'с': 'c',
'т': 't',
'у': 'y',
'х': 'x',
'Ѕ': 'S', // Cyrillic Dze (looks like S)
'ѕ': 's',
'І': 'I', // Cyrillic I
'і': 'i',
'Ј': 'J', // Cyrillic Je
'ј': 'j',
// Greek letters that look like Latin
'Α': 'A', // Alpha
'Β': 'B', // Beta
'Ε': 'E', // Epsilon
'Ζ': 'Z', // Zeta
'Η': 'H', // Eta
'Ι': 'I', // Iota
'Κ': 'K', // Kappa
'Μ': 'M', // Mu
'Ν': 'N', // Nu
'Ο': 'O', // Omicron
'Ρ': 'P', // Rho
'Τ': 'T', // Tau
'Υ': 'Y', // Upsilon
'Χ': 'X', // Chi
'α': 'a',
'β': 'b',
'ε': 'e',
'ζ': 'z',
'η': 'h',
'ι': 'i',
'κ': 'k',
'μ': 'm',
'ν': 'n',
'ο': 'o',
'ρ': 'p',
'τ': 't',
'υ': 'y',
'χ': 'x',
};
return text.split('').map((char) => homoglyphMap[char] ?? char).join('');
}
+113
View File
@@ -518,6 +518,10 @@ class EVMChainClient {
}
Future<Erc20Token?> getErc20TokenFromMoralis(String contractAddress, String chainName) async {
if (secrets.moralisApiKey.isEmpty) {
printV('Moralis API key is empty, cannot fetch token info');
return null;
}
final uri = Uri.https(
'deep-index.moralis.io',
'/api/v2.2/erc20/metadata',
@@ -567,6 +571,93 @@ class EVMChainClient {
);
}
Future<List<MoralisWalletTokenBalance>> fetchWalletTokensFromMoralis(
String address,
String chainName,
) async {
try {
if (secrets.moralisApiKey.isEmpty) {
printV('Moralis API key is empty, cannot fetch wallet tokens');
return [];
}
final uri = Uri.https(
'deep-index.moralis.io',
'/api/v2.2/$address/erc20',
{
"chain": chainName,
},
);
final response = await client.get(
uri,
headers: {
"Accept": "application/json",
"X-API-Key": secrets.moralisApiKey,
},
);
if (response.statusCode < 200 || response.statusCode >= 300) {
printV('Moralis API returned invalid status code: ${response.statusCode}');
return [];
}
final decodedResponse = jsonDecode(response.body) as List;
final List<MoralisWalletTokenBalance> tokens = [];
for (final item in decodedResponse) {
final tokenData = item as Map<String, dynamic>;
final balanceStr = tokenData['balance'] as String? ?? '0';
final balanceWei = BigInt.tryParse(balanceStr) ?? BigInt.zero;
if (balanceWei == BigInt.zero) continue;
final contractAddress = (tokenData['token_address'] as String? ?? '').toLowerCase();
final name = (tokenData['name'] as String? ?? '').toString();
final symbol = (tokenData['symbol'] as String? ?? '').toString();
final symbolFiltered = symbol.replaceFirst(RegExp('^\\\$'), '');
final decimalsRaw = tokenData['decimals'];
final decimals =
decimalsRaw is int ? decimalsRaw : int.tryParse(decimalsRaw.toString()) ?? 18;
final logo = tokenData['logo'] as String?;
final thumbnail = tokenData['thumbnail'] as String?;
final iconUrl = logo ?? thumbnail;
final possibleSpamRaw = tokenData['possible_spam'];
final possibleSpam = possibleSpamRaw is bool
? possibleSpamRaw
: (possibleSpamRaw.toString().toLowerCase() == 'true');
final verifiedContractRaw = tokenData['verified_contract'];
final verifiedContract = verifiedContractRaw is bool
? verifiedContractRaw
: (verifiedContractRaw.toString().toLowerCase() == 'true');
tokens.add(
MoralisWalletTokenBalance(
contractAddress: contractAddress,
name: name,
symbol: symbolFiltered,
decimals: decimals,
iconUrl: iconUrl,
balanceWei: balanceWei,
possibleSpam: possibleSpam,
verifiedContract: verifiedContract,
),
);
}
return tokens;
} catch (e, stackTrace) {
printV('Error fetching wallet tokens from Moralis: ${e.toString()}');
printV('Stack trace: ${stackTrace.toString()}');
return [];
}
}
Uint8List hexToBytes(String hexString) {
return Uint8List.fromList(
hex.HEX.decode(hexString.startsWith('0x') ? hexString.substring(2) : hexString));
@@ -599,3 +690,25 @@ class EVMChainClient {
// return exponent;
// }
}
class MoralisWalletTokenBalance {
final String contractAddress;
final String name;
final String symbol;
final int decimals;
final String? iconUrl;
final BigInt balanceWei;
final bool possibleSpam;
final bool verifiedContract;
MoralisWalletTokenBalance({
required this.contractAddress,
required this.name,
required this.symbol,
required this.decimals,
this.iconUrl,
required this.balanceWei,
required this.possibleSpam,
required this.verifiedContract,
});
}
+94 -12
View File
@@ -15,6 +15,7 @@ import 'package:cw_core/pending_transaction.dart';
import 'package:cw_core/sync_status.dart';
import 'package:cw_core/transaction_direction.dart';
import 'package:cw_core/transaction_priority.dart';
import 'package:cw_core/utils/homoglyph_normalizer.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/wallet_addresses.dart';
import 'package:cw_core/wallet_base.dart';
@@ -440,24 +441,50 @@ abstract class EVMChainWalletBase
await save();
}
Future<void> _checkForExistingScamTokens() async {
bool isTokenPropertiesSuspicious(Erc20Token token) {
final baseCurrencySymbols = CryptoCurrency.all.map((e) => e.title.toUpperCase()).toList();
bool isTokenWhitelisted = getDefaultTokenContractAddresses
.any((element) => element.toLowerCase() == token.contractAddress.toLowerCase());
// Normalize the token data to check for homoglyph spoofing attack, characters that look like ASCII (Cyrillic, Greek, etc.)
final normalizedName = normalizeHomoglyphs(token.name.trim().toUpperCase());
final normalizedSymbol = normalizeHomoglyphs(token.symbol.trim().toUpperCase());
final normalizedTitle = normalizeHomoglyphs(token.title.trim().toUpperCase());
final suspiciousStrings = [
't.me',
'.me',
'telegram',
'http',
'https',
'.com',
'airdrop',
'www',
'.xyz',
'🎁',
];
final hasSuspiciousData = suspiciousStrings.any(
(element) =>
normalizedName.toLowerCase().contains(element) ||
normalizedSymbol.toLowerCase().contains(element) ||
normalizedTitle.toLowerCase().contains(element),
);
// Check if the token symbol is the same as any of the base currencies symbols (ETH, SOL, POL, TRX, etc).
// If it is, then it's probably a scam unless it's in the whitelist.
final hasSuspiciousSymbol = baseCurrencySymbols.contains(normalizedSymbol);
return hasSuspiciousData || (hasSuspiciousSymbol && !isTokenWhitelisted);
}
Future<void> _checkForExistingScamTokens() async {
for (var token in erc20Currencies) {
bool isPotentialScam = false;
bool isWhitelisted = getDefaultTokenContractAddresses
.any((element) => element.toLowerCase() == token.contractAddress.toLowerCase());
final tokenSymbol = token.title.toUpperCase();
// check if the token symbol is the same as any of the base currencies symbols (ETH, SOL, POL, TRX, etc):
// if it is, then it's probably a scam unless it's in the whitelist
if (baseCurrencySymbols.contains(tokenSymbol.trim().toUpperCase()) && !isWhitelisted) {
if (isTokenPropertiesSuspicious(token)) {
isPotentialScam = true;
}
if (isPotentialScam) {
token.isPotentialScam = true;
token.iconPath = null;
await token.save();
@@ -482,6 +509,61 @@ abstract class EVMChainWalletBase
}
}
Future<List<Erc20Token>> discoverTokensFromMoralis() async {
try {
if (!evmChainErc20TokensBox.isOpen) return [];
final address = walletAddresses.address;
if (address.isEmpty) return [];
final chainName = EVMChainUtils.getDefaultTokenSymbol(selectedChainId).toLowerCase();
final walletTokens = await _client.fetchWalletTokensFromMoralis(address, chainName);
if (walletTokens.isEmpty) return [];
final existingTokenAddresses = {
for (final token in evmChainErc20TokensBox.values)
token.contractAddress.toLowerCase(): token,
};
final whitelistedTokenAddresses =
getDefaultTokenContractAddresses.map((a) => a.toLowerCase()).toSet();
final List<Erc20Token> newTokens = [];
for (final token in walletTokens) {
final addr = token.contractAddress.toLowerCase();
final existingToken = existingTokenAddresses[addr];
if (existingToken != null) {
if (whitelistedTokenAddresses.contains(addr) && !existingToken.enabled) {
existingToken.enabled = true;
await existingToken.save();
await addErc20Token(existingToken);
}
continue;
}
final newToken = Erc20Token(
name: token.name,
symbol: token.symbol,
contractAddress: addr,
decimal: token.decimals,
iconPath: token.iconUrl,
tag: EVMChainUtils.getDefaultTokenTag(selectedChainId),
isPotentialScam: token.possibleSpam,
);
newTokens.add(newToken);
}
return newTokens;
} catch (e) {
printV('Error discovering tokens from Moralis: ${e.toString()}');
return [];
}
}
@override
int calculateEstimatedFee(TransactionPriority priority, int? amount) => 0;
+52
View File
@@ -513,4 +513,56 @@ class CWEVM extends EVM {
@override
bool hasPriorityFee(int chainId) => EVMChainUtils.hasPriorityFee(chainId);
@override
Future<bool> checkTokenFiatPrice(WalletBase wallet, Erc20Token token) async {
try {
final settingsStore = getIt.get<SettingsStore>();
final fiatCurrency = settingsStore.fiatCurrency;
final torOnly = settingsStore.fiatApiMode == FiatApiMode.torOnly;
final price = await FiatConversionService.fetchPrice(
crypto: token,
fiat: fiatCurrency,
torOnly: torOnly,
);
return price > 0;
} catch (e) {
return false;
}
}
@override
Future<void> discoverAndAddWalletTokens(WalletBase wallet) async {
if (wallet is! EVMChainWallet) return;
try {
final discoveredTokens = await wallet.discoverTokensFromMoralis();
if (discoveredTokens.isEmpty) return;
final List<Future<void>> tokenChecks = [];
for (final token in discoveredTokens) {
tokenChecks.add((() async {
final isPropertiesSuspicious = wallet.isTokenPropertiesSuspicious(token);
bool hasValidFiatPrice = true;
if (!isPropertiesSuspicious) {
hasValidFiatPrice = await checkTokenFiatPrice(wallet, token);
}
final isSpam = isPropertiesSuspicious || !hasValidFiatPrice;
token.isPotentialScam = isSpam;
token.enabled = !isSpam;
await wallet.addErc20Token(token);
})());
}
await Future.wait(tokenChecks);
} catch (_) {}
}
}
@@ -107,6 +107,10 @@ void startCurrentWalletChangeReaction(
await wallet.walletInfo.save();
}
if (isEVMCompatibleChain(wallet.type)) {
await evm!.discoverAndAddWalletTokens(wallet);
}
} catch (e) {
printV(e.toString());
}
@@ -101,78 +101,83 @@ class BalanceRowWidget extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: hasAdditionalBalance
? () => _showBalanceDescription(
context, S.of(context).available_balance_description)
: null,
child: Row(
children: [
Semantics(
hint: 'Double tap to see more information',
container: true,
child: Text(
'${availableBalanceLabel}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
height: 1,
),
),
),
if (hasAdditionalBalance)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Icon(
Icons.help_outline,
size: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: hasAdditionalBalance
? () => _showBalanceDescription(
context, S.of(context).available_balance_description)
: null,
child: Row(
children: [
Semantics(
hint: 'Double tap to see more information',
container: true,
child: Text(
'${availableBalanceLabel}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
height: 1,
),
),
),
],
if (hasAdditionalBalance)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Icon(
Icons.help_outline,
size: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
),
SizedBox(height: 6),
AutoSizeText(
availableBalance,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w900,
fontSize: 24,
height: 1,
),
maxLines: 1,
textAlign: TextAlign.start,
),
SizedBox(height: 6),
if (isTestnet)
Text(
S.of(context).testnet_coins_no_value,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
SizedBox(height: 6),
AutoSizeText(
availableBalance,
minFontSize: 16,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w900,
fontSize: 24,
height: 1,
),
maxLines: 1,
textAlign: TextAlign.start,
),
if (!isTestnet)
Text(
'${availableFiatBalance}',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurfaceVariant,
height: 1,
),
),
],
SizedBox(height: 6),
if (isTestnet)
Text(
S.of(context).testnet_coins_no_value,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
height: 1,
),
),
if (!isTestnet)
Text(
'${availableFiatBalance}',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurfaceVariant,
height: 1,
),
),
],
),
),
SizedBox(
//width: min(MediaQuery.of(context).size.width * 0.2, 100),
child: Center(
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
CakeImageWidget(
imageUrl: currency.iconPath,
@@ -7,6 +7,7 @@ import 'package:cw_core/transaction_history.dart';
import 'package:cw_core/wallet_base.dart';
import 'package:cw_core/balance.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/erc20_token.dart';
import 'package:cw_core/transaction_info.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:cake_wallet/generated/i18n.dart';
@@ -378,6 +379,20 @@ abstract class BalanceViewModelBase with Store {
if (a.asset == wallet.currency) return -1;
}
if (isEVMCompatibleChain(wallet.type)) {
final aIsToken = a.asset is Erc20Token;
final bIsToken = b.asset is Erc20Token;
final aHasBalance = (double.tryParse(a.availableBalance) ?? 0) > 0;
final bHasBalance = (double.tryParse(b.availableBalance) ?? 0) > 0;
// Adding this so tokens with balance come before tokens without balance
if (aIsToken && bIsToken) {
if (aHasBalance && !bHasBalance) return -1;
if (!aHasBalance && bHasBalance) return 1;
}
}
switch (sortBalanceBy) {
case SortBalanceBy.FiatBalance:
final aFiatBalance = _getFiatBalance(
+7
View File
@@ -1345,6 +1345,10 @@ import 'package:web3dart/web3dart.dart';
""";
const evmCWHeaders = """
import 'package:cake_wallet/core/fiat_conversion_service.dart';
import 'package:cake_wallet/di.dart';
import 'package:cake_wallet/entities/fiat_api_mode.dart';
import 'package:cake_wallet/store/settings_store.dart';
import 'package:cw_evm/utils/evm_chain_formatter.dart';
import 'package:cw_evm/evm_chain_mnemonics.dart';
import 'package:cw_evm/evm_chain_registry.dart';
@@ -1518,6 +1522,9 @@ abstract class EVM {
String? getExplorerUrlForChainId(int chainId, {bool showProtocol = true});
bool hasPriorityFee(int chainId);
Future<bool> checkTokenFiatPrice(WalletBase wallet, Erc20Token token);
Future<void> discoverAndAddWalletTokens(WalletBase wallet);
}
class ChainInfo {