From a7073df4224e4a80f3dc9b93345561bac5cbc692 Mon Sep 17 00:00:00 2001 From: David Adegoke <64401859+Blazebrain@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:52:19 +0100 Subject: [PATCH] feat: harden spam detection for solana and evm (#3322) * feat: harden spam detection for solana and evm * fix: guard scam/impersonator tokens from auto-enabling on EVM and Solana * feat: enhance token property validation for scam detection --- cw_evm/lib/clients/evm_chain_client.dart | 177 +++++++++++++++-------- cw_evm/lib/evm_chain_wallet.dart | 138 ++++++++++++------ cw_solana/lib/solana_wallet.dart | 124 ++++++++++++++++ lib/evm/cw_evm.dart | 46 +++--- lib/solana/cw_solana.dart | 4 +- 5 files changed, 361 insertions(+), 128 deletions(-) diff --git a/cw_evm/lib/clients/evm_chain_client.dart b/cw_evm/lib/clients/evm_chain_client.dart index 2751e94bb..21973e30c 100644 --- a/cw_evm/lib/clients/evm_chain_client.dart +++ b/cw_evm/lib/clients/evm_chain_client.dart @@ -618,74 +618,117 @@ class EVMChainClient { 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; - + const maxPages = 3; + String? cursor; + int pageCount = 0; final List tokens = []; - for (final item in decodedResponse) { - final tokenData = item as Map; + do { + final params = { + "chain": chainName, + if (cursor != null && cursor.isNotEmpty) "cursor": cursor, + }; - 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, - ), + final uri = Uri.https( + 'deep-index.moralis.io', + '/api/v2.2/wallets/$address/tokens', + params, ); - } + + 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 tokens; + } + + final decoded = jsonDecode(response.body); + if (decoded is! Map) return tokens; + + final result = decoded['result']; + if (result is! List) return tokens; + + for (final item in result) { + if (item is! Map) continue; + final tokenData = item; + + final nativeRaw = tokenData['native_token']; + final nativeToken = nativeRaw is bool + ? nativeRaw + : (nativeRaw?.toString().toLowerCase() == 'true'); + if (nativeToken) continue; + + 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'); + + final usdPriceRaw = tokenData['usd_price']; + final double? usdPrice = usdPriceRaw is num + ? usdPriceRaw.toDouble() + : (usdPriceRaw is String ? double.tryParse(usdPriceRaw) : null); + + final usdValueRaw = tokenData['usd_value']; + final double? usdValue = usdValueRaw is num + ? usdValueRaw.toDouble() + : (usdValueRaw is String ? double.tryParse(usdValueRaw) : null); + + final securityRaw = tokenData['security_score']; + final int? securityScore = securityRaw is int + ? securityRaw + : (securityRaw is num + ? securityRaw.toInt() + : (securityRaw is String ? int.tryParse(securityRaw) : null)); + + tokens.add( + MoralisWalletTokenBalance( + contractAddress: contractAddress, + name: name, + symbol: symbolFiltered, + decimals: decimals, + iconUrl: iconUrl, + balanceWei: balanceWei, + possibleSpam: possibleSpam, + verifiedContract: verifiedContract, + usdPrice: usdPrice, + usdValue: usdValue, + securityScore: securityScore, + ), + ); + } + + final nextCursor = decoded['cursor']; + cursor = nextCursor is String && nextCursor.isNotEmpty ? nextCursor : null; + pageCount++; + } while (cursor != null && pageCount < maxPages); return tokens; } catch (e, stackTrace) { @@ -737,6 +780,9 @@ class MoralisWalletTokenBalance { final BigInt balanceWei; final bool possibleSpam; final bool verifiedContract; + final double? usdPrice; + final double? usdValue; + final int? securityScore; MoralisWalletTokenBalance({ required this.contractAddress, @@ -747,5 +793,8 @@ class MoralisWalletTokenBalance { required this.balanceWei, required this.possibleSpam, required this.verifiedContract, + this.usdPrice, + this.usdValue, + this.securityScore, }); } diff --git a/cw_evm/lib/evm_chain_wallet.dart b/cw_evm/lib/evm_chain_wallet.dart index 4b002e396..6cd05f75c 100644 --- a/cw_evm/lib/evm_chain_wallet.dart +++ b/cw_evm/lib/evm_chain_wallet.dart @@ -545,67 +545,112 @@ abstract class EVMChainWalletBase await save(); } - bool isTokenPropertiesSuspicious(Erc20Token token) { - bool isTokenWhitelisted = getDefaultTokenContractAddresses - .any((element) => element.toLowerCase() == token.contractAddress.toLowerCase()); + static const _urlLikeSuspiciousMarkers = [ + 't.me', + '.me', + 'telegram', + 'http', + 'https', + '.com', + '.org', + '.top', + '.live', + '.xyz', + 'www', + '🎁', + 'airdrop', + 'distribution', + ]; - final defaultTokenSymbols = EVMChainDefaultTokens.getDefaultTokenSymbols(selectedChainId); + static final _suspiciousWordPattern = + RegExp(r'\b(bot|claim|reward)\b', caseSensitive: false); + + static const _knownNonEvmNativeSymbols = { + 'ICP', + 'SOL', + 'TRX', + 'ATOM', + 'DOT', + 'ADA', + 'XRP', + 'XLM', + 'XMR', + 'ALGO', + 'NEAR', + 'TON', + 'HBAR', + 'APT', + 'SUI', + 'KAS', + }; + + static bool _hasSuspiciousData(String normalized) { + final lower = normalized.toLowerCase(); + if (_urlLikeSuspiciousMarkers.any(lower.contains)) return true; + return _suspiciousWordPattern.hasMatch(lower); + } + + bool isTokenPropertiesSuspicious( + Erc20Token token, { + Set? cachedWhitelistLower, + Set? cachedDefaultSymbolsUpper, + }) { + final whitelistLower = cachedWhitelistLower ?? + getDefaultTokenContractAddresses.map((a) => a.toLowerCase()).toSet(); + final defaultSymbolsUpper = cachedDefaultSymbolsUpper ?? + EVMChainDefaultTokens.getDefaultTokenSymbols(selectedChainId).toSet(); + + final isTokenWhitelisted = whitelistLower.contains(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', - '.org', - '.top', - '.live', - 'airdrop', - 'reward', - 'distribution', - 'www', - '.xyz', - '🎁', - 'bot', - 'claim', - 'reward', - ]; - - final hasSuspiciousData = suspiciousStrings.any( - (element) => - normalizedName.toLowerCase().contains(element) || - normalizedSymbol.toLowerCase().contains(element) || - normalizedTitle.toLowerCase().contains(element), - ); + final hasSuspiciousData = _hasSuspiciousData(normalizedName) || + _hasSuspiciousData(normalizedSymbol) || + _hasSuspiciousData(normalizedTitle); final nativeSymbol = currency.title.toUpperCase(); final hasSuspiciousNativeSymbol = normalizedSymbol == nativeSymbol && !isTokenWhitelisted; final hasSuspiciousDefaultTokenSymbol = - defaultTokenSymbols.contains(normalizedSymbol) && !isTokenWhitelisted; + defaultSymbolsUpper.contains(normalizedSymbol) && !isTokenWhitelisted; - return hasSuspiciousData || hasSuspiciousNativeSymbol || hasSuspiciousDefaultTokenSymbol; + final hasSuspiciousNonEvmNativeSymbol = + _knownNonEvmNativeSymbols.contains(normalizedSymbol) && !isTokenWhitelisted; + + return hasSuspiciousData || + hasSuspiciousNativeSymbol || + hasSuspiciousDefaultTokenSymbol || + hasSuspiciousNonEvmNativeSymbol; } - Future _checkForExistingScamTokens() async { - for (var token in erc20Currencies) { - bool isPotentialScam = false; + String get _scamCheckDoneKey => 'evm_scam_check_v2_done_${walletInfo.name}'; - if (isTokenPropertiesSuspicious(token)) { - isPotentialScam = true; + Future _checkForExistingScamTokens() async { + final prefs = await sharedPrefs.future; + if (prefs.getBool(_scamCheckDoneKey) == true) return; + + final whitelistLower = + getDefaultTokenContractAddresses.map((a) => a.toLowerCase()).toSet(); + final defaultSymbolsUpper = + EVMChainDefaultTokens.getDefaultTokenSymbols(selectedChainId).toSet(); + + for (var token in erc20Currencies) { + final suspicious = isTokenPropertiesSuspicious( + token, + cachedWhitelistLower: whitelistLower, + cachedDefaultSymbolsUpper: defaultSymbolsUpper, + ); + + if (suspicious && !token.isPotentialScam) { token.isPotentialScam = true; token.iconPath = null; await token.save(); + continue; } - // For fixing wrongly classified tokens - if (!isPotentialScam && token.isPotentialScam) { + if (!suspicious && token.isPotentialScam) { token.isPotentialScam = false; if (token.iconPath == null || token.iconPath!.isEmpty) { @@ -621,6 +666,8 @@ abstract class EVMChainWalletBase await token.save(); } } + + await prefs.setBool(_scamCheckDoneKey, true); } Future discoverTokensFromMoralis() async { @@ -672,6 +719,9 @@ abstract class EVMChainWalletBase DiscoveredToken( token: newToken, balanceWei: token.balanceWei, + verifiedContract: token.verifiedContract, + moralisUsdPrice: token.usdPrice, + moralisUsdValue: token.usdValue, ), ); } @@ -1757,10 +1807,16 @@ class GasParamsHandler { class DiscoveredToken { final Erc20Token token; final BigInt balanceWei; + final bool verifiedContract; + final double? moralisUsdPrice; + final double? moralisUsdValue; const DiscoveredToken({ required this.token, required this.balanceWei, + required this.verifiedContract, + this.moralisUsdPrice, + this.moralisUsdValue, }); } diff --git a/cw_solana/lib/solana_wallet.dart b/cw_solana/lib/solana_wallet.dart index 48a1b0e71..34308b048 100644 --- a/cw_solana/lib/solana_wallet.dart +++ b/cw_solana/lib/solana_wallet.dart @@ -12,6 +12,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'; @@ -135,6 +136,8 @@ abstract class SolanaWalletBase splTokensBox = await CakeHive.openBox(boxName); + await _checkForExistingScamTokens(); + // Create the privatekey using either the mnemonic or the privateKey _solanaPrivateKey = await getPrivateKey( mnemonic: _mnemonic, @@ -153,6 +156,36 @@ abstract class SolanaWalletBase await save(); } + String get _scamCheckDoneKey => 'solana_scam_check_v2_done_${walletInfo.name}'; + + Future _checkForExistingScamTokens() async { + if (!splTokensBox.isOpen) return; + + final prefs = await _sharedPrefs.future; + if (prefs.getBool(_scamCheckDoneKey) == true) return; + + final defaultMints = + DefaultSPLTokens().initialSPLTokens.map((t) => t.mintAddress).toSet(); + final defaultSymbolsUpper = DefaultSPLTokens() + .initialSPLTokens + .map((t) => t.symbol.toUpperCase()) + .toSet(); + + for (final token in splTokensBox.values) { + final suspicious = isTokenPropertiesSuspicious( + token, + cachedDefaultMints: defaultMints, + cachedDefaultSymbolsUpper: defaultSymbolsUpper, + ); + if (suspicious && !token.isPotentialScam) { + token.isPotentialScam = true; + await token.save(); + } + } + + await prefs.setBool(_scamCheckDoneKey, true); + } + Future getPrivateKey({ String? mnemonic, String? privateKey, @@ -671,7 +704,98 @@ abstract class SolanaWalletBase } } + static const _urlLikeSuspiciousMarkers = [ + 't.me', + '.me', + 'telegram', + 'http', + 'https', + '.com', + '.org', + '.top', + '.live', + '.xyz', + 'www', + '🎁', + 'airdrop', + 'distribution', + ]; + + static final _suspiciousWordPattern = + RegExp(r'\b(bot|claim|reward)\b', caseSensitive: false); + + static const _knownNonSolanaNativeSymbols = { + 'BTC', + 'ETH', + 'BNB', + 'AVAX', + 'MATIC', + 'POL', + 'ICP', + 'TRX', + 'ATOM', + 'DOT', + 'ADA', + 'XRP', + 'XLM', + 'XMR', + 'ALGO', + 'NEAR', + 'TON', + 'HBAR', + 'APT', + 'SUI', + 'KAS', + }; + + static bool _hasSuspiciousData(String normalized) { + final lower = normalized.toLowerCase(); + if (_urlLikeSuspiciousMarkers.any(lower.contains)) return true; + return _suspiciousWordPattern.hasMatch(lower); + } + + bool isTokenPropertiesSuspicious( + SPLToken token, { + Set? cachedDefaultMints, + Set? cachedDefaultSymbolsUpper, + }) { + final defaultMints = cachedDefaultMints ?? + DefaultSPLTokens().initialSPLTokens.map((t) => t.mintAddress).toSet(); + final defaultSymbolsUpper = cachedDefaultSymbolsUpper ?? + DefaultSPLTokens() + .initialSPLTokens + .map((t) => t.symbol.toUpperCase()) + .toSet(); + + final isTokenWhitelisted = defaultMints.contains(token.mintAddress); + + final normalizedName = normalizeHomoglyphs(token.name.trim().toUpperCase()); + final normalizedSymbol = normalizeHomoglyphs(token.symbol.trim().toUpperCase()); + final normalizedTitle = normalizeHomoglyphs(token.title.trim().toUpperCase()); + + final hasSuspiciousData = _hasSuspiciousData(normalizedName) || + _hasSuspiciousData(normalizedSymbol) || + _hasSuspiciousData(normalizedTitle); + + const nativeSymbol = 'SOL'; + final hasSuspiciousNativeSymbol = normalizedSymbol == nativeSymbol && !isTokenWhitelisted; + + final hasSuspiciousDefaultTokenSymbol = + defaultSymbolsUpper.contains(normalizedSymbol) && !isTokenWhitelisted; + + final hasSuspiciousNonSolanaNativeSymbol = + _knownNonSolanaNativeSymbols.contains(normalizedSymbol) && !isTokenWhitelisted; + + return hasSuspiciousData || + hasSuspiciousNativeSymbol || + hasSuspiciousDefaultTokenSymbol || + hasSuspiciousNonSolanaNativeSymbol; + } + Future addSPLToken(SPLToken token) async { + final isSuspicious = isTokenPropertiesSuspicious(token); + token.isPotentialScam = token.isPotentialScam || isSuspicious; + await splTokensBox.put(token.mintAddress, token); if (token.enabled) { diff --git a/lib/evm/cw_evm.dart b/lib/evm/cw_evm.dart index 2a028e5a1..0b28b7ee0 100644 --- a/lib/evm/cw_evm.dart +++ b/lib/evm/cw_evm.dart @@ -692,29 +692,18 @@ class CWEVM extends EVM { ); } - Future<({double usdValue, bool hasValidFiatPrice})> _getTokenUsdValueAndFiatCheck( - Erc20Token token, - BigInt balanceWei, - ) async { + Future _fetchFiatApiPriceForToken(Erc20Token token) async { try { final settingsStore = getIt.get(); final torOnly = settingsStore.fiatApiMode == FiatApiMode.torOnly; - final price = await FiatConversionService.fetchPrice( + return await FiatConversionService.fetchPrice( crypto: token, fiat: FiatCurrency.usd, torOnly: torOnly, ); - - final hasValidFiatPrice = price > 0; - - final decimals = token.decimal; - final balance = balanceWei.toDouble() / math.pow(10, decimals); - final usdValue = balance * price; - - return (usdValue: usdValue, hasValidFiatPrice: hasValidFiatPrice); - } catch (e) { - return (usdValue: 0.0, hasValidFiatPrice: false); + } catch (_) { + return 0.0; } } @@ -730,21 +719,34 @@ class CWEVM extends EVM { final List> tokenChecks = []; + final whitelistedContracts = + wallet.getDefaultTokenContractAddresses.map((a) => a.toLowerCase()).toSet(); + for (final item in result.newTokens) { tokenChecks.add((() async { final token = item.token; final isPropertiesSuspicious = wallet.isTokenPropertiesSuspicious(token); + final isWhitelisted = whitelistedContracts.contains(token.contractAddress.toLowerCase()); - final fiatResult = await _getTokenUsdValueAndFiatCheck( - token, - item.balanceWei, - ); - final isSpam = isPropertiesSuspicious || !fiatResult.hasValidFiatPrice; + final moralisPrice = item.moralisUsdPrice; + final moralisValue = item.moralisUsdValue ?? 0.0; + final hasMoralisPrice = moralisPrice != null && moralisPrice > 0; + + final fiatApiPrice = await _fetchFiatApiPriceForToken(token); + final hasFiatApiPrice = fiatApiPrice > 0; + + final isImpersonator = + hasFiatApiPrice && !hasMoralisPrice && !isWhitelisted && !item.verifiedContract; + + final isSpam = isPropertiesSuspicious || + token.isPotentialScam || + isImpersonator || + (!hasMoralisPrice && !hasFiatApiPrice); token.isPotentialScam = isSpam; - - token.enabled = (fiatResult.usdValue >= _minTokenUsdValue) && !isSpam; + token.enabled = + hasMoralisPrice && hasFiatApiPrice && (moralisValue >= _minTokenUsdValue) && !isSpam; await wallet.addErc20Token(token); })()); diff --git a/lib/solana/cw_solana.dart b/lib/solana/cw_solana.dart index 569a603e2..83c06f9d9 100644 --- a/lib/solana/cw_solana.dart +++ b/lib/solana/cw_solana.dart @@ -387,9 +387,11 @@ class CWSolana extends Solana { tokenChecks.add((() async { final token = item.token; + final isPropertiesSuspicious = wallet.isTokenPropertiesSuspicious(token); + final fiatResult = await _getTokenUsdValueAndFiatCheck(token, item.balance); - final isSpam = !fiatResult.hasValidFiatPrice; + final isSpam = isPropertiesSuspicious || !fiatResult.hasValidFiatPrice; token.isPotentialScam = isSpam; token.enabled = (fiatResult.usdValue >= _minTokenUsdValue) && !isSpam;