feat: harden spam detection for solana and evm (#3322)
What changed, and why it matters
This commit strengthens the wallet's automatic detection and handling of scam, spam, and impersonator tokens on Ethereum-compatible (EVM) chains and Solana. It adds checks for suspicious names/symbols (e.g., fake 'ETH', 'SOL', 'USDC', or tokens with 'airdrop', 'claim', URLs), prevents auto-enabling tokens that look like well-known coins but aren't verified, and uses Moralis API data such as price, value, verified-contract status, and security score to decide whether a discovered token should be shown as enabled. It also re-runs a one-time cleanup on existing tokens to reclassify any previously missed scams. There is no direct exploit fixed in the diff; rather, it is a defensive hardening change against social-engineering/token-spam attacks.
Treat as a security-hardening improvement and include in the next release. Review the new heuristics for false positives that could hide legitimate user-added tokens. Ensure the Moralis API key and pagination behavior handle rate limits. No emergency patch is indicated by the diff alone.
Security signals we found
Hardens token spam/scam classification for EVM and Solana
Adds Moralis security_score, usd_price, usd_value, verified_contract ingestion
Adds homoglyph normalization and symbol-impersonation detection
Prevents auto-enabling impersonator tokens that lack Moralis price or verified contract
One-time re-scan of existing tokens to reclassify missed scams
No direct exploit or key-leakage fix visible in diff
Evidence from the diff
The patch refactors token discovery and classification in cw_evm and cw_solana. Key changes: (1) EVM Moralis client now calls /api/v2.2/wallets/$address/tokens with pagination (max 3 pages), parses native_token, usd_price, usd_value, security_score, and verified_contract fields, and skips native tokens and zero-balance entries. (2) EVM wallet’s isTokenPropertiesSuspicious now normalizes homoglyphs, caches whitelist/default-symbol sets, detects URL-like markers and words (bot/claim/reward), flags native-chain symbol impersonation, default-token symbol impersonation, and non-EVM native symbol impersonation (BTC, SOL, XMR, etc.). (3) A one-time checkForExistingScamTokens re-scan is gated by a shared-preferences flag (evm_scam_check_v2_done*) and corrects both false negatives and false positives. (4) CWEVM token enabling logic now requires both Moralis price > 0 and fiat API price > 0, Moralis USD value >= _minTokenUsdValue, not suspicious, and not an ‘impersonator’ (has fiat price but no Moralis price, not whitelisted, not verified). (5) Solana gets equivalent property checks and a one-time existing-token re-scan, plus isSpam now includes suspicious properties. The commit is defensive; it does not patch a remote code execution or key-extraction vulnerability.
Changed components
cw_evm/lib/clients/evm_chain_client.dartcw_evm/lib/evm_chain_wallet.dartcw_solana/lib/solana_wallet.dartlib/evm/cw_evm.dartlib/solana/cw_solana.dartInspect captured patch +357 / −124
diff --git a/cw_evm/lib/clients/evm_chain_client.dart b/cw_evm/lib/clients/evm_chain_client.dart
index 2751e94b..21973e30 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',
- {
+ const maxPages = 3;
+ String? cursor;
+ int pageCount = 0;
+ final List<MoralisWalletTokenBalance> tokens = [];
+
+ do {
+ final params = <String, String>{
"chain": chainName,
- },
- );
+ if (cursor != null && cursor.isNotEmpty) "cursor": cursor,
+ };
- final response = await client.get(
- uri,
- headers: {
- "Accept": "application/json",
- "X-API-Key": secrets.moralisApiKey,
- },
- );
+ final uri = Uri.https(
+ 'deep-index.moralis.io',
+ '/api/v2.2/wallets/$address/tokens',
+ params,
+ );
- if (response.statusCode < 200 || response.statusCode >= 300) {
- printV('Moralis API returned invalid status code: ${response.statusCode}');
- return [];
- }
+ final response = await client.get(
+ uri,
+ headers: {
+ "Accept": "application/json",
+ "X-API-Key": secrets.moralisApiKey,
+ },
+ );
- final decodedResponse = jsonDecode(response.body) as List;
+ if (response.statusCode < 200 || response.statusCode >= 300) {
+ printV('Moralis API returned invalid status code: ${response.statusCode}');
+ return tokens;
+ }
- final List<MoralisWalletTokenBalance> 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,
+ ),
+ );
+ }
- 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,
- ),
- );
- }
+ 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 4b002e39..6cd05f75 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',
+ ];
+
+ 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 defaultTokenSymbols = EVMChainDefaultTokens.getDefaultTokenSymbols(selectedChainId);
+ 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;
}
+ String get _scamCheckDoneKey => 'evm_scam_check_v2_done_${walletInfo.name}';
+
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) {
- bool isPotentialScam = false;
+ final suspicious = isTokenPropertiesSuspicious(
+ token,
+ cachedWhitelistLower: whitelistLower,
+ cachedDefaultSymbolsUpper: defaultSymbolsUpper,
+ );
- if (isTokenPropertiesSuspicious(token)) {
- isPotentialScam = true;
+ 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,
});
}
diff --git a/cw_solana/lib/solana_wallet.dart b/cw_solana/lib/solana_wallet.dart
index 48a1b0e7..34308b04 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<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) {
diff --git a/lib/evm/cw_evm.dart b/lib/evm/cw_evm.dart
index 2a028e5a..0b28b7ee 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<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;
- token.isPotentialScam = isSpam;
+ final fiatApiPrice = await _fetchFiatApiPriceForToken(token);
+ final hasFiatApiPrice = fiatApiPrice > 0;
+
+ final isImpersonator =
+ hasFiatApiPrice && !hasMoralisPrice && !isWhitelisted && !item.verifiedContract;
- token.enabled = (fiatResult.usdValue >= _minTokenUsdValue) && !isSpam;
+ final isSpam = isPropertiesSuspicious ||
+ token.isPotentialScam ||
+ isImpersonator ||
+ (!hasMoralisPrice && !hasFiatApiPrice);
+
+ token.isPotentialScam = 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 569a603e..83c06f9d 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;
Why this scored 51/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.