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
This commit is contained in:
David Adegoke
2026-07-10 14:52:19 +03:00
committed by GitHub
parent c9e0ac85d4
commit a7073df422
5 changed files with 361 additions and 128 deletions
+113 -64
View File
@@ -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<MoralisWalletTokenBalance> tokens = [];
for (final item in decodedResponse) {
final tokenData = item as Map<String, dynamic>;
do {
final params = <String, String>{
"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<String, dynamic>) return tokens;
final result = decoded['result'];
if (result is! List) return tokens;
for (final item in result) {
if (item is! Map<String, dynamic>) 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,
});
}
+97 -41
View File
@@ -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<String>? cachedWhitelistLower,
Set<String>? 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<void> _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<void> _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<MoralisDiscoveryResult> 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,
});
}
+124
View File
@@ -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<SPLToken>(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<void> _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<SolanaPrivateKey> 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<String>? cachedDefaultMints,
Set<String>? 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<void> addSPLToken(SPLToken token) async {
final isSuspicious = isTokenPropertiesSuspicious(token);
token.isPotentialScam = token.isPotentialScam || isSuspicious;
await splTokensBox.put(token.mintAddress, token);
if (token.enabled) {
+24 -22
View File
@@ -692,29 +692,18 @@ class CWEVM extends EVM {
);
}
Future<({double usdValue, bool hasValidFiatPrice})> _getTokenUsdValueAndFiatCheck(
Erc20Token token,
BigInt balanceWei,
) async {
Future<double> _fetchFiatApiPriceForToken(Erc20Token token) async {
try {
final settingsStore = getIt.get<SettingsStore>();
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<Future<void>> 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);
})());
+3 -1
View File
@@ -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;