diff --git a/cw_core/lib/spl_token.dart b/cw_core/lib/spl_token.dart index 145d7bd037..88323e175c 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 5ceb7f7562..5d3d2b818c 100644 --- a/cw_solana/lib/solana_client.dart +++ b/cw_solana/lib/solana_client.dart @@ -1856,4 +1856,89 @@ class SolanaWalletClient { return null; } } + + Future> 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 tokens = []; + + for (final item in decodedResponse) { + final tokenData = item as Map; + + 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 7ffc1b24fa..ab826505a4 100644 --- a/cw_solana/lib/solana_wallet.dart +++ b/cw_solana/lib/solana_wallet.dart @@ -630,6 +630,65 @@ abstract class SolanaWalletBase } } + Future 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 = []; + + 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 addSPLToken(SPLToken token) async { await splTokensBox.put(token.mintAddress, token); @@ -765,3 +824,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 newTokens; + + const SolanaMoralisDiscoveryResult({required this.newTokens}); + + static const SolanaMoralisDiscoveryResult empty = SolanaMoralisDiscoveryResult(newTokens: []); +} diff --git a/lib/new-ui/widgets/coins_page/assets_history/asset_details_modal.dart b/lib/new-ui/widgets/coins_page/assets_history/asset_details_modal.dart index 032dfad033..346c6015a0 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/asset_details_modal.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/asset_details_modal.dart @@ -7,12 +7,12 @@ import 'package:cake_wallet/new-ui/pages/send_page.dart'; import 'package:cake_wallet/new-ui/pages/swap_page.dart'; import 'package:cake_wallet/new-ui/widgets/modern_button.dart'; import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart'; +import 'package:cake_wallet/src/widgets/cake_image_widget.dart'; import 'package:cake_wallet/utils/payment_request.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/unspent_coin_type.dart'; import 'package:cw_core/wallet_base.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_svg/flutter_svg.dart'; import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; enum AssetDetailsModalModes { normal, ltcTransparent, ltcPrivate } @@ -99,8 +99,8 @@ class AssetDetailsModal extends StatelessWidget { color: Colors.white), child: Padding( padding: const EdgeInsets.all(4.0), - child: SvgPicture.asset( - chainIconPath, + child: CakeImageWidget( + imageUrl: chainIconPath, width: 18, height: 18, colorFilter: diff --git a/lib/new-ui/widgets/coins_page/assets_history/asset_tile.dart b/lib/new-ui/widgets/coins_page/assets_history/asset_tile.dart index a23dfa2579..a04a1626ca 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/asset_tile.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/asset_tile.dart @@ -1,10 +1,10 @@ import 'package:cake_wallet/new-ui/widgets/coins_page/assets_history/asset_details_modal.dart'; import 'package:cake_wallet/src/screens/wallet_connect/utils/string_parsing.dart'; +import 'package:cake_wallet/src/widgets/cake_image_widget.dart'; import 'package:cake_wallet/view_model/dashboard/balance_view_model.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/wallet_base.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_svg/flutter_svg.dart'; class AssetTile extends StatelessWidget { const AssetTile( @@ -90,7 +90,7 @@ class AssetTile extends StatelessWidget { child: Stack( children: [ if((iconPath).isNotEmpty) - Image.asset(iconPath) + CakeImageWidget(imageUrl: iconPath) else Container( width: 45, @@ -115,8 +115,8 @@ class AssetTile extends StatelessWidget { color: Colors.white), child: Padding( padding: const EdgeInsets.all(2.0), - child: SvgPicture.asset( - chainIconPath, + child: CakeImageWidget( + imageUrl: chainIconPath, width: 12, height: 12, colorFilter: diff --git a/lib/reactions/on_current_wallet_change.dart b/lib/reactions/on_current_wallet_change.dart index 8c23db2dff..9fa4caad04 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 55d5448ba3..76b2591be3 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(); + 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 discoverAndAddWalletTokens(WalletBase wallet) async { + if (wallet is! SolanaWallet) return; + + try { + final result = await wallet.discoverTokensFromMoralis(); + + if (result.newTokens.isEmpty) return; + + final List> 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 8dc57f4fb6..556ccafb9a 100644 --- a/lib/view_model/dashboard/balance_view_model.dart +++ b/lib/view_model/dashboard/balance_view_model.dart @@ -15,6 +15,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'; @@ -376,9 +377,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 f54e50ff26..402183c2cc 100644 --- a/tool/configure.dart +++ b/tool/configure.dart @@ -935,6 +935,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'; @@ -1012,6 +1017,8 @@ abstract class Solana { WalletBase wallet, { List? tokenMints, }); + + Future discoverAndAddWalletTokens(WalletBase wallet); } class JupiterSwapFailedException implements Exception {