feat: Auto fetch wallet tokens in solana wallets (#2983)
What changed, and why it matters
This commit adds a feature that automatically discovers Solana tokens in a user's wallet by querying an external service (Moralis). It then adds those tokens to the wallet's token list, optionally enabling them if they appear to have real-world value. The change also makes a previously read-only 'potential scam' flag editable so the app can mark newly discovered tokens as spam. There is no direct evidence in the commit of a vulnerability being fixed or introduced, but the feature involves external API calls, automatic token enabling, and spam classification, which carry security and privacy considerations.
Review the Moralis API integration for privacy implications (wallet address sharing), validate and sanitize all fields parsed from the API response, ensure the fiat-price-based spam/heuristic cannot be manipulated by an attacker listing a token with a fake price, and confirm that automatically enabling tokens does not expose users to phishing or scam tokens. Consider whether the $0.10 threshold and 'no price = scam' logic are appropriate security controls.
Security signals we found
External API call to Moralis with API key for wallet address token discovery
Automatic enabling of discovered tokens based on fiat value threshold
Automatic marking of tokens without fiat price as potential scam
Wallet address sent to third-party service (Moralis)
Change of SPLToken.isPotentialScam from immutable to mutable
No visible input validation on Moralis response fields beyond null/empty checks
Evidence from the diff
The commit introduces fetchWalletTokensFromMoralis() in SolanaWalletClient to call Moralis’s /account/mainnet/{address}/tokens endpoint using an API key. SolanaWalletBase.discoverTokensFromMoralis() fetches balances, looks up token metadata, and returns discovered SPL tokens. CWSolana.discoverAndAddWalletTokens() is called when the current wallet changes; it fetches USD prices via FiatConversionService.fetchPrice(), marks tokens without a price as isPotentialScam = true, and enables tokens whose USD value is at least $0.10. SPLToken.isPotentialScam was changed from final to mutable to support this. BalanceViewModel was updated to treat Solana token assets similarly to EVM tokens for sorting.
Changed components
cw_solana/lib/solana_client.dartcw_solana/lib/solana_wallet.dartlib/solana/cw_solana.dartlib/reactions/on_current_wallet_change.dartlib/view_model/dashboard/balance_view_model.dartcw_core/lib/spl_token.darttool/configure.dartInspect captured patch +236 / −4
diff --git a/cw_core/lib/spl_token.dart b/cw_core/lib/spl_token.dart
index 145d7bd0..88323e17 100644
--- a/cw_core/lib/spl_token.dart
+++ b/cw_core/lib/spl_token.dart
@@ -35,7 +35,7 @@ class SPLToken extends CryptoCurrency with HiveObjectMixin {
@override
@HiveField(8, defaultValue: false)
- final bool isPotentialScam;
+ bool isPotentialScam;
SPLToken({
required this.name,
diff --git a/cw_solana/lib/solana_client.dart b/cw_solana/lib/solana_client.dart
index aa86c15f..543e2802 100644
--- a/cw_solana/lib/solana_client.dart
+++ b/cw_solana/lib/solana_client.dart
@@ -1858,4 +1858,89 @@ class SolanaWalletClient {
return null;
}
}
+
+ Future<List<MoralisSolanaTokenBalance>> fetchWalletTokensFromMoralis(
+ String address,
+ ) async {
+ try {
+ if (secrets.moralisApiKey.isEmpty) {
+ printV('Moralis API key is empty, cannot fetch wallet tokens');
+ return [];
+ }
+
+ final uri = Uri.https(
+ 'solana-gateway.moralis.io',
+ '/account/mainnet/$address/tokens',
+ );
+
+ final response = await client.get(
+ uri,
+ headers: {
+ "Accept": "application/json",
+ "X-API-Key": secrets.moralisApiKey,
+ },
+ );
+
+ if (response.statusCode < 200 || response.statusCode >= 300) {
+ printV(
+ 'Moralis Solana API returned status: '
+ '${response.statusCode}',
+ );
+ return [];
+ }
+
+ final decodedResponse = jsonDecode(response.body) as List;
+
+ final List<MoralisSolanaTokenBalance> tokens = [];
+
+ for (final item in decodedResponse) {
+ final tokenData = item as Map<String, dynamic>;
+
+ final amountStr = tokenData['amount'] as String? ?? '0';
+ final amount = double.tryParse(amountStr) ?? 0.0;
+
+ if (amount <= 0) continue;
+
+ final mint = tokenData['mint'] as String? ?? '';
+ if (mint.isEmpty) continue;
+
+ final amountRaw = tokenData['amountRaw'] as String? ?? '0';
+
+ final decimals = tokenData['decimals'] as int? ?? 0;
+
+ final associatedTokenAddress = tokenData['associatedTokenAddress'] as String? ?? '';
+
+ tokens.add(
+ MoralisSolanaTokenBalance(
+ mint: mint,
+ amount: amount,
+ amountRaw: amountRaw,
+ decimals: decimals,
+ associatedTokenAddress: associatedTokenAddress,
+ ),
+ );
+ }
+
+ return tokens;
+ } catch (e) {
+ printV('Error fetching wallet tokens from Moralis: ${e.toString()}');
+ return [];
+ }
+ }
+}
+
+class MoralisSolanaTokenBalance {
+ final String mint;
+ final double amount;
+ final String amountRaw;
+ final int decimals;
+ final String associatedTokenAddress;
+
+ const MoralisSolanaTokenBalance({
+ required this.mint,
+ required this.amount,
+ required this.amountRaw,
+ required this.decimals,
+ required this.associatedTokenAddress,
+ });
}
diff --git a/cw_solana/lib/solana_wallet.dart b/cw_solana/lib/solana_wallet.dart
index 51ed325e..76f2d892 100644
--- a/cw_solana/lib/solana_wallet.dart
+++ b/cw_solana/lib/solana_wallet.dart
@@ -631,6 +631,65 @@ abstract class SolanaWalletBase
}
}
+ Future<SolanaMoralisDiscoveryResult> discoverTokensFromMoralis() async {
+ try {
+ if (!splTokensBox.isOpen) return SolanaMoralisDiscoveryResult.empty;
+
+ final address = walletAddresses.address;
+ if (address.isEmpty) return SolanaMoralisDiscoveryResult.empty;
+
+ final walletTokens = await _client.fetchWalletTokensFromMoralis(address);
+ if (walletTokens.isEmpty) return SolanaMoralisDiscoveryResult.empty;
+
+ final existingMints = {
+ for (final token in splTokensBox.values) token.mintAddress: token,
+ };
+
+ final defaultMints = DefaultSPLTokens().initialSPLTokens.map((t) => t.mintAddress).toSet();
+
+ final newTokens = <DiscoveredSPLToken>[];
+
+ for (final moralisToken in walletTokens) {
+ final mint = moralisToken.mint;
+
+ final existingToken = existingMints[mint];
+ if (existingToken != null) {
+ if (defaultMints.contains(mint) && !existingToken.enabled) {
+ existingToken.enabled = true;
+ await existingToken.save();
+ await addSPLToken(existingToken);
+ }
+ continue;
+ }
+
+ final tokenInfo = await _client.fetchSPLTokenInfo(mint);
+ if (tokenInfo == null) continue;
+
+ final discoveredToken = SPLToken(
+ name: tokenInfo.name,
+ symbol: tokenInfo.symbol,
+ mintAddress: mint,
+ decimal: moralisToken.decimals,
+ mint: tokenInfo.mint,
+ iconPath: tokenInfo.iconPath,
+ tag: 'SOL',
+ );
+
+ newTokens.add(
+ DiscoveredSPLToken(
+ token: discoveredToken,
+ balance: moralisToken.amount,
+ ),
+ );
+ }
+
+ return SolanaMoralisDiscoveryResult(newTokens: newTokens);
+ } catch (e) {
+ printV('Error discovering SPL tokens from Moralis: ${e.toString()}');
+ return SolanaMoralisDiscoveryResult.empty;
+ }
+ }
+
Future<void> addSPLToken(SPLToken token) async {
await splTokensBox.put(token.mintAddress, token);
@@ -766,3 +825,21 @@ abstract class SolanaWalletBase
@override
final String? passphrase;
}
+
+class DiscoveredSPLToken {
+ final SPLToken token;
+ final double balance;
+
+ const DiscoveredSPLToken({
+ required this.token,
+ required this.balance,
+ });
+}
+
+class SolanaMoralisDiscoveryResult {
+ final List<DiscoveredSPLToken> newTokens;
+
+ const SolanaMoralisDiscoveryResult({required this.newTokens});
+
+ static const SolanaMoralisDiscoveryResult empty = SolanaMoralisDiscoveryResult(newTokens: []);
+}
diff --git a/lib/reactions/on_current_wallet_change.dart b/lib/reactions/on_current_wallet_change.dart
index 8c23db2d..9fa4caad 100644
--- a/lib/reactions/on_current_wallet_change.dart
+++ b/lib/reactions/on_current_wallet_change.dart
@@ -111,6 +111,11 @@ void startCurrentWalletChangeReaction(
if (isEVMCompatibleChain(wallet.type)) {
await evm!.discoverAndAddWalletTokens(wallet);
}
+
+ if (wallet.type == WalletType.solana) {
+ await solana!
+ .discoverAndAddWalletTokens(wallet);
+ }
} catch (e) {
printV(e.toString());
}
diff --git a/lib/solana/cw_solana.dart b/lib/solana/cw_solana.dart
index 55d5448b..76b2591b 100644
--- a/lib/solana/cw_solana.dart
+++ b/lib/solana/cw_solana.dart
@@ -354,4 +354,59 @@ class CWSolana extends Solana {
final solanaWallet = wallet as SolanaWallet;
await solanaWallet.updateTokenBalance(tokenMints: tokenMints);
}
+
+ static const _minTokenUsdValue = 0.1;
+
+ Future<({double usdValue, bool hasValidFiatPrice})> _getTokenUsdValueAndFiatCheck(
+ SPLToken token,
+ double balance,
+ ) async {
+ try {
+ final settingsStore = getIt.get<SettingsStore>();
+ final torOnly = settingsStore.fiatApiMode == FiatApiMode.torOnly;
+
+ final price = await FiatConversionService.fetchPrice(
+ crypto: token,
+ fiat: FiatCurrency.usd,
+ torOnly: torOnly,
+ );
+
+ final hasValidFiatPrice = price > 0;
+ final usdValue = balance * price;
+
+ return (usdValue: usdValue, hasValidFiatPrice: hasValidFiatPrice);
+ } catch (e) {
+ return (usdValue: 0.0, hasValidFiatPrice: false);
+ }
+ }
+
+ @override
+ Future<void> discoverAndAddWalletTokens(WalletBase wallet) async {
+ if (wallet is! SolanaWallet) return;
+
+ try {
+ final result = await wallet.discoverTokensFromMoralis();
+
+ if (result.newTokens.isEmpty) return;
+
+ final List<Future<void>> tokenChecks = [];
+
+ for (final item in result.newTokens) {
+ tokenChecks.add((() async {
+ final token = item.token;
+
+ final fiatResult = await _getTokenUsdValueAndFiatCheck(token, item.balance);
+
+ final isSpam = !fiatResult.hasValidFiatPrice;
+
+ token.isPotentialScam = isSpam;
+ token.enabled = (fiatResult.usdValue >= _minTokenUsdValue) && !isSpam;
+
+ await wallet.addSPLToken(token);
+ })());
+ }
+
+ await Future.wait(tokenChecks);
+ } catch (_) {}
+ }
}
diff --git a/lib/view_model/dashboard/balance_view_model.dart b/lib/view_model/dashboard/balance_view_model.dart
index d8d82e8d..bfcd2141 100644
--- a/lib/view_model/dashboard/balance_view_model.dart
+++ b/lib/view_model/dashboard/balance_view_model.dart
@@ -19,6 +19,7 @@ import 'package:cake_wallet/store/settings_store.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/spl_token.dart';
import 'package:cw_core/transaction_info.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:mobx/mobx.dart';
@@ -382,9 +383,11 @@ 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 isTokenWallet = isEVMCompatibleChain(wallet.type) || wallet.type == WalletType.solana;
+
+ if (isTokenWallet) {
+ final aIsToken = a.asset is Erc20Token || a.asset is SPLToken;
+ final bIsToken = b.asset is Erc20Token || b.asset is SPLToken;
final aHasBalance = (double.tryParse(a.availableBalance) ?? 0) > 0;
final bHasBalance = (double.tryParse(b.availableBalance) ?? 0) > 0;
diff --git a/tool/configure.dart b/tool/configure.dart
index 507b6900..80856320 100644
--- a/tool/configure.dart
+++ b/tool/configure.dart
@@ -942,6 +942,11 @@ import 'package:cw_solana/pending_solana_transaction.dart';
import 'package:cw_solana/solana_transaction_credentials.dart';
import 'package:cw_solana/solana_wallet_creation_credentials.dart';
import 'package:cw_solana/default_spl_tokens.dart';
+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/entities/fiat_currency.dart';
+import 'package:cake_wallet/store/settings_store.dart';
import 'dart:convert';
import 'dart:typed_data';
@@ -1019,6 +1024,8 @@ abstract class Solana {
WalletBase wallet, {
List<String>? tokenMints,
});
+
+ Future<void> discoverAndAddWalletTokens(WalletBase wallet);
}
class JupiterSwapFailedException implements Exception {
Why this scored 39/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.