diff --git a/cw_core/lib/utils/homoglyph_normalizer.dart b/cw_core/lib/utils/homoglyph_normalizer.dart new file mode 100644 index 000000000..36d15dde4 --- /dev/null +++ b/cw_core/lib/utils/homoglyph_normalizer.dart @@ -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(''); +} diff --git a/cw_evm/lib/clients/evm_chain_client.dart b/cw_evm/lib/clients/evm_chain_client.dart index 5bb5d1ab1..c1676c5de 100644 --- a/cw_evm/lib/clients/evm_chain_client.dart +++ b/cw_evm/lib/clients/evm_chain_client.dart @@ -518,6 +518,10 @@ class EVMChainClient { } Future 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> 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 tokens = []; + + for (final item in decodedResponse) { + final tokenData = item as Map; + + 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, + }); +} diff --git a/cw_evm/lib/evm_chain_wallet.dart b/cw_evm/lib/evm_chain_wallet.dart index c7f20a792..b9fa6dcd7 100644 --- a/cw_evm/lib/evm_chain_wallet.dart +++ b/cw_evm/lib/evm_chain_wallet.dart @@ -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 _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 _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> 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 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; diff --git a/lib/evm/cw_evm.dart b/lib/evm/cw_evm.dart index 952a2a048..eff7c40ca 100644 --- a/lib/evm/cw_evm.dart +++ b/lib/evm/cw_evm.dart @@ -513,4 +513,56 @@ class CWEVM extends EVM { @override bool hasPriorityFee(int chainId) => EVMChainUtils.hasPriorityFee(chainId); + + @override + Future checkTokenFiatPrice(WalletBase wallet, Erc20Token token) async { + try { + final settingsStore = getIt.get(); + 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 discoverAndAddWalletTokens(WalletBase wallet) async { + if (wallet is! EVMChainWallet) return; + + try { + final discoveredTokens = await wallet.discoverTokensFromMoralis(); + + if (discoveredTokens.isEmpty) return; + + final List> 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 (_) {} + } } diff --git a/lib/reactions/on_current_wallet_change.dart b/lib/reactions/on_current_wallet_change.dart index d37436755..8c23db2df 100644 --- a/lib/reactions/on_current_wallet_change.dart +++ b/lib/reactions/on_current_wallet_change.dart @@ -107,6 +107,10 @@ void startCurrentWalletChangeReaction( await wallet.walletInfo.save(); } + + if (isEVMCompatibleChain(wallet.type)) { + await evm!.discoverAndAddWalletTokens(wallet); + } } catch (e) { printV(e.toString()); } diff --git a/lib/src/screens/dashboard/pages/balance/balance_row_widget.dart b/lib/src/screens/dashboard/pages/balance/balance_row_widget.dart index 766914f76..e2fa67063 100644 --- a/lib/src/screens/dashboard/pages/balance/balance_row_widget.dart +++ b/lib/src/screens/dashboard/pages/balance/balance_row_widget.dart @@ -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, diff --git a/lib/view_model/dashboard/balance_view_model.dart b/lib/view_model/dashboard/balance_view_model.dart index ded394207..8bdd78cba 100644 --- a/lib/view_model/dashboard/balance_view_model.dart +++ b/lib/view_model/dashboard/balance_view_model.dart @@ -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( diff --git a/tool/configure.dart b/tool/configure.dart index 4e2fda080..074c3f1c4 100644 --- a/tool/configure.dart +++ b/tool/configure.dart @@ -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 checkTokenFiatPrice(WalletBase wallet, Erc20Token token); + Future discoverAndAddWalletTokens(WalletBase wallet); } class ChainInfo {