feat(spl): implement Solana token selection
What changed, and why it matters
This commit adds the ability for users to select Solana-based tokens (SPL tokens) in the Stack Wallet app. It is a feature implementation, not a security fix. The new code includes placeholder/stub functions that do not yet perform real Solana blockchain checks, and it adds debug logging that prints wallet addresses and selected token details during development. There is no direct evidence in the commit of a vulnerability being fixed or introduced.
Treat this as a normal feature commit. Before release, ensure the TODO placeholders in SolanaTokenAPI are replaced with real RPC calls, remove or gate debug logging that exposes wallet addresses, add validation for mint/owner addresses, and verify that mock balances cannot be displayed to users. No immediate security response is indicated by the diff alone.
Security signals we found
New network-facing API service added but not yet wired to real RPC calls
Placeholder/mock return values in financial-balance functions could mislead users if shipped
Debug logging of wallet receiving address and selected token mints in UI code
No input validation visible for mint addresses passed to API
No security-relevant commit message or patch markers
Evidence from the diff
The commit extends the existing Ethereum token selection UI to also support Solana SPL tokens. It introduces a new SolanaTokenAPI service, a DefaultSplTokens list, and wires the edit_wallet_tokens_view to handle SolanaWallet. The API methods (getTokenAccountsByOwner, getTokenAccountBalance, getTokenSupply, getTokenAccountInfo, ownsToken, findAssociatedTokenAddress) are explicitly marked as placeholders/TODOs and return mock data. The UI’s AddTokenListElementData now accepts any Contract rather than only EthContract. Debug print statements log the wallet name, receiving address, and selected mint addresses during token ownership checks.
Changed components
lib/pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dartlib/pages/add_wallet_views/add_token_view/sub_widgets/add_token_list_element.dartlib/services/solana/solana_token_api.dartlib/utilities/default_spl_tokens.dartInspect captured patch +481 / −23
diff --git a/lib/pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart b/lib/pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart
index b1f07ce..fb37dd2 100644
--- a/lib/pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart
+++ b/lib/pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart
@@ -16,19 +16,23 @@ import 'package:flutter_svg/svg.dart';
import 'package:isar_community/isar.dart';
import '../../../db/isar/main_db.dart';
+import '../../../models/isar/models/contract.dart';
import '../../../models/isar/models/ethereum/eth_contract.dart';
import '../../../notifications/show_flush_bar.dart';
import '../../../pages_desktop_specific/desktop_home_view.dart';
import '../../../providers/global/price_provider.dart';
+import '../../../providers/global/solana_token_api_provider.dart';
import '../../../providers/global/wallets_provider.dart';
import '../../../themes/stack_colors.dart';
import '../../../utilities/assets.dart';
import '../../../utilities/constants.dart';
import '../../../utilities/default_eth_tokens.dart';
+import '../../../utilities/default_spl_tokens.dart';
import '../../../utilities/text_styles.dart';
import '../../../utilities/util.dart';
import '../../../wallets/isar/providers/wallet_info_provider.dart';
import '../../../wallets/wallet/impl/ethereum_wallet.dart';
+import '../../../wallets/wallet/impl/solana_wallet.dart';
import '../../../widgets/background.dart';
import '../../../widgets/conditional_parent.dart';
import '../../../widgets/custom_buttons/app_bar_icon_button.dart';
@@ -102,10 +106,91 @@ class _EditWalletTokensViewState extends ConsumerState<EditWalletTokensView> {
.map((e) => e.token.address)
.toList();
- final ethWallet =
- ref.read(pWallets).getWallet(widget.walletId) as EthereumWallet;
+ final wallet = ref.read(pWallets).getWallet(widget.walletId);
- await ethWallet.updateTokenContracts(selectedTokens);
+ // Handle Ethereum tokens.
+ if (wallet is EthereumWallet) {
+ await wallet.updateTokenContracts(selectedTokens);
+ }
+ // Handle Solana tokens.
+ else if (wallet is SolanaWallet) {
+ // Get WalletInfo and update Solana token mint addresses.
+ final walletInfo = wallet.info;
+ await walletInfo.updateSolanaTokenMintAddresses(
+ newMintAddresses: selectedTokens.toSet(),
+ isar: MainDB.instance.isar,
+ );
+
+ // Log selected tokens and verify ownership.
+ debugPrint('===== SOLANA TOKEN OWNERSHIP CHECK =====');
+ debugPrint('Wallet: ${walletInfo.name}');
+ debugPrint('Selected token mint addresses: $selectedTokens');
+
+ // Get wallet's receiving address for ownership checks.
+ try {
+ final receivingAddressObj = await wallet.getCurrentReceivingAddress();
+ if (receivingAddressObj == null) {
+ debugPrint('Error: Could not get wallet receiving address');
+ return;
+ }
+ final receivingAddress = receivingAddressObj.value;
+ debugPrint('Wallet address: $receivingAddress');
+ debugPrint('');
+
+ // Check ownership of each selected token.
+ for (final mintAddress in selectedTokens) {
+ // Find the token entity to get token details.
+ final tokenEntity = tokenEntities.firstWhere(
+ (e) => e.token.address == mintAddress,
+ orElse: () => AddTokenListElementData(
+ // Fallback contract with just the address
+ EthContract(
+ address: mintAddress,
+ name: 'Unknown Token',
+ symbol: mintAddress,
+ decimals: 0,
+ type: EthContractType.erc20,
+ ),
+ ),
+ );
+
+ final tokenName = tokenEntity.token.name;
+ final tokenSymbol = tokenEntity.token.symbol;
+
+ debugPrint('Token: $tokenName ($tokenSymbol)');
+ debugPrint(' Mint: $mintAddress');
+
+ // Check if wallet owns this token using the API.
+ try {
+ // Note: ownsToken() is currently a placeholder returning false.
+ // Once Solana RPC integration is complete, this will check real ownership.
+ final tokenApi = ref.read(solanaTokenApiProvider);
+ final ownershipResult = await tokenApi.ownsToken(
+ receivingAddress,
+ mintAddress,
+ );
+
+ if (ownershipResult.isSuccess) {
+ if (ownershipResult.value == true) {
+ debugPrint('OWNS token - token account found');
+ } else {
+ debugPrint('DOES NOT own token - no token account found');
+ }
+ } else {
+ debugPrint(
+ 'Error checking ownership: ${ownershipResult.exception}',
+ );
+ }
+ } catch (e) {
+ debugPrint('Exception checking ownership: $e');
+ }
+ }
+
+ debugPrint('========================================');
+ } catch (e) {
+ debugPrint('Error getting wallet address: $e');
+ }
+ }
if (mounted) {
if (widget.contractsToMarkSelected == null) {
Navigator.of(context).pop(42);
@@ -123,7 +208,7 @@ class _EditWalletTokensViewState extends ConsumerState<EditWalletTokensView> {
unawaited(
showFloatingFlushBar(
type: FlushBarType.success,
- message: "${ethWallet.info.name} tokens saved",
+ message: "${wallet.info.name} tokens saved",
context: context,
),
);
@@ -175,19 +260,29 @@ class _EditWalletTokensViewState extends ConsumerState<EditWalletTokensView> {
_searchFieldController = TextEditingController();
_searchFocusNode = FocusNode();
- final contracts =
- MainDB.instance.getEthContracts().sortByName().findAllSync();
+ final wallet = ref.read(pWallets).getWallet(widget.walletId);
- if (contracts.isEmpty) {
- contracts.addAll(DefaultTokens.list);
- MainDB.instance
- .putEthContracts(contracts)
- .then(
- (_) => ref.read(priceAnd24hChangeNotifierProvider).updatePrice(),
- );
- }
+ // Load appropriate tokens based on wallet type.
+ if (wallet is SolanaWallet) {
+ // Load Solana tokens (SPL tokens).
+ final splTokens = DefaultSplTokens.list;
+ tokenEntities.addAll(splTokens.map((e) => AddTokenListElementData(e)));
+ } else {
+ // Load Ethereum tokens (default behavior for Ethereum wallets).
+ final contracts =
+ MainDB.instance.getEthContracts().sortByName().findAllSync();
+
+ if (contracts.isEmpty) {
+ contracts.addAll(DefaultTokens.list);
+ MainDB.instance
+ .putEthContracts(contracts)
+ .then(
+ (_) => ref.read(priceAnd24hChangeNotifierProvider).updatePrice(),
+ );
+ }
- tokenEntities.addAll(contracts.map((e) => AddTokenListElementData(e)));
+ tokenEntities.addAll(contracts.map((e) => AddTokenListElementData(e)));
+ }
final walletContracts = ref.read(pWalletTokenAddresses(widget.walletId));
diff --git a/lib/pages/add_wallet_views/add_token_view/sub_widgets/add_token_list_element.dart b/lib/pages/add_wallet_views/add_token_view/sub_widgets/add_token_list_element.dart
index a477c57..eecf914 100644
--- a/lib/pages/add_wallet_views/add_token_view/sub_widgets/add_token_list_element.dart
+++ b/lib/pages/add_wallet_views/add_token_view/sub_widgets/add_token_list_element.dart
@@ -14,6 +14,7 @@ import 'package:flutter_svg/flutter_svg.dart';
import 'package:isar_community/isar.dart';
import '../../../../models/isar/exchange_cache/currency.dart';
+import '../../../../models/isar/models/contract.dart';
import '../../../../models/isar/models/ethereum/eth_contract.dart';
import '../../../../services/exchange/change_now/change_now_exchange.dart';
import '../../../../services/exchange/exchange_data_loading_service.dart';
@@ -29,7 +30,7 @@ import '../../../../widgets/rounded_white_container.dart';
class AddTokenListElementData {
AddTokenListElementData(this.token);
- final EthContract token;
+ final Contract token;
bool selected = false;
}
@@ -102,13 +103,7 @@ class _AddTokenListElementState extends ConsumerState<AddTokenListElement> {
placeholderBuilder:
(_) => AppIcon(width: iconSize, height: iconSize),
)
- : SvgPicture.asset(
- widget.data.token.symbol == "BNB"
- ? Assets.svg.bnbIcon
- : Assets.svg.ethereum,
- width: iconSize,
- height: iconSize,
- ),
+ : AppIcon(width: iconSize, height: iconSize),
const SizedBox(width: 12),
ConditionalParent(
condition: isDesktop,
diff --git a/lib/services/solana/solana_token_api.dart b/lib/services/solana/solana_token_api.dart
new file mode 100644
index 0000000..63e0161
--- /dev/null
+++ b/lib/services/solana/solana_token_api.dart
@@ -0,0 +1,318 @@
+/*
+ * This file is part of Stack Wallet.
+ *
+ * Copyright (c) 2025 Cypher Stack
+ * All Rights Reserved.
+ * The code is distributed under GPLv3 license, see LICENSE file for details.
+ *
+ */
+
+import 'package:solana/solana.dart';
+
+/// Exception for Solana token API errors.
+class SolanaTokenApiException implements Exception {
+ final String message;
+ final Exception? originalException;
+
+ SolanaTokenApiException(
+ this.message, {
+ this.originalException,
+ });
+
+ @override
+ String toString() => 'SolanaTokenApiException: $message';
+}
+
+/// Response wrapper for Solana token API calls.
+///
+/// Follows the pattern that the result is either value or exception
+class SolanaTokenApiResponse<T> {
+ final T? value;
+ final Exception? exception;
+
+ SolanaTokenApiResponse({
+ this.value,
+ this.exception,
+ });
+
+ bool get isSuccess => exception == null && value != null;
+ bool get isError => exception != null;
+
+ @override
+ String toString() =>
+ isSuccess ? 'Success($value)' : 'Error($exception)';
+}
+
+/// Data class for token account information.
+class TokenAccountInfo {
+ final String address;
+ final String owner;
+ final String mint;
+ final BigInt balance;
+ final int decimals;
+ final bool isNative;
+
+ TokenAccountInfo({
+ required this.address,
+ required this.owner,
+ required this.mint,
+ required this.balance,
+ required this.decimals,
+ required this.isNative,
+ });
+
+ factory TokenAccountInfo.fromJson(String address, Map<String, dynamic> json) {
+ Map<String, dynamic>? parsed;
+ Map<String, dynamic>? infoMap;
+
+ try {
+ final data = json['data'];
+ if (data is Map) {
+ final dataMap = Map<String, dynamic>.from(data);
+ final parsedVal = dataMap['parsed'];
+ if (parsedVal is Map) {
+ parsed = Map<String, dynamic>.from(parsedVal);
+ }
+ }
+ if (parsed != null) {
+ final infoVal = parsed['info'];
+ if (infoVal is Map) {
+ infoMap = Map<String, dynamic>.from(infoVal);
+ }
+ }
+ } catch (e) {
+ // Silently ignore parsing errors, use empty map
+ }
+
+ final info = infoMap ?? <String, dynamic>{};
+
+ final owner = info['owner'];
+ final mint = info['mint'];
+ final tokenAmount = info['tokenAmount'];
+ final amountStr = (tokenAmount is Map) ? (tokenAmount as Map<String, dynamic>)['amount'] : null;
+ final decimalsVal = (tokenAmount is Map) ? (tokenAmount as Map<String, dynamic>)['decimals'] : null;
+
+ final isNative = (parsed is Map)
+ ? ((parsed as Map<String, dynamic>)['type'] == 'account' &&
+ (parsed as Map<String, dynamic>)['program'] == 'spl-token')
+ : false;
+
+ return TokenAccountInfo(
+ address: address,
+ owner: owner is String ? owner : (owner?.toString() ?? ''),
+ mint: mint is String ? mint : (mint?.toString() ?? ''),
+ balance: BigInt.parse((amountStr?.toString() ?? '0')),
+ decimals: decimalsVal is int ? decimalsVal : (int.tryParse(decimalsVal?.toString() ?? '0') ?? 0),
+ isNative: isNative,
+ );
+ }
+
+ @override
+ String toString() =>
+ 'TokenAccountInfo(address=$address, owner=$owner, mint=$mint, balance=$balance, decimals=$decimals)';
+}
+
+/// Solana SPL Token API service.
+///
+/// Provides methods to interact with Solana token accounts and metadata
+/// using RPC calls. Uses the solana package's RpcClient under the hood.
+class SolanaTokenAPI {
+ static final SolanaTokenAPI _instance = SolanaTokenAPI._internal();
+
+ factory SolanaTokenAPI() {
+ return _instance;
+ }
+
+ SolanaTokenAPI._internal();
+
+ RpcClient? _rpcClient;
+
+ /// Initialize with a configured RPC client.
+ /// This should be called with the same RPC client from SolanaWallet.
+ void initializeRpcClient(RpcClient rpcClient) {
+ _rpcClient = rpcClient;
+ }
+
+ void _checkClient() {
+ if (_rpcClient == null) {
+ throw SolanaTokenApiException(
+ 'RPC client not initialized. Call initializeRpcClient() first.',
+ );
+ }
+ }
+
+ /// Get token accounts owned by a wallet address for a specific mint.
+ ///
+ /// Parameters:
+ /// - ownerAddress: The wallet address to query
+ /// - mint: (Optional) Filter by specific token mint address
+ ///
+ /// Returns a list of token account addresses.
+ ///
+ /// Currently returns placeholder data for UI development.
+ /// TODO: Implement full RPC call with proper TokenAccountsFilter.
+ Future<SolanaTokenApiResponse<List<String>>> getTokenAccountsByOwner(
+ String ownerAddress, {
+ String? mint,
+ }) async {
+ try {
+ _checkClient();
+
+ // TODO: Implement actual RPC call when solana package APIs are stable.
+ // For now, return placeholder token account address derived from owner and mint.
+ if (mint != null) {
+ // Placeholder: In production, derive Associated Token Account (ATA)
+ // using findAssociatedTokenAddress.
+ return SolanaTokenApiResponse<List<String>>(
+ value: ['TokenAccount_${ownerAddress}_$mint'],
+ );
+ }
+
+ return SolanaTokenApiResponse<List<String>>(value: []);
+ } on Exception catch (e) {
+ return SolanaTokenApiResponse<List<String>>(
+ exception: SolanaTokenApiException(
+ 'Failed to get token accounts: ${e.toString()}',
+ originalException: e,
+ ),
+ );
+ }
+ }
+
+ /// Get the balance of a specific token account.
+ ///
+ /// Parameters:
+ /// - tokenAccountAddress: The token account address to query.
+ ///
+ /// Returns the balance as a BigInt (in smallest units).
+ /// NOTE: Currently returns placeholder data for UI development
+ /// TODO: Implement full RPC call when API is ready
+ Future<SolanaTokenApiResponse<BigInt>> getTokenAccountBalance(
+ String tokenAccountAddress,
+ ) async {
+ try {
+ _checkClient();
+
+ // TODO: Query account info to get token amount when RPC APIs are stable
+ // For now return placeholder mock data
+ return SolanaTokenApiResponse<BigInt>(
+ value: BigInt.from(1000000),
+ );
+ } on Exception catch (e) {
+ return SolanaTokenApiResponse<BigInt>(
+ exception: SolanaTokenApiException(
+ 'Failed to get token balance: ${e.toString()}',
+ originalException: e,
+ ),
+ );
+ }
+ }
+
+ /// Get the total supply of a token.
+ ///
+ /// Parameters:
+ /// - mint: The token mint address.
+ ///
+ /// Returns the total supply as a BigInt.
+ /// NOTE: Currently returns placeholder data for UI development
+ /// TODO: Implement full RPC call when API is ready
+ Future<SolanaTokenApiResponse<BigInt>> getTokenSupply(String mint) async {
+ try {
+ _checkClient();
+
+ // TODO: Get the mint account info when RPC APIs are stable
+ // For now return placeholder mock data
+ return SolanaTokenApiResponse<BigInt>(
+ value: BigInt.parse('1000000000000000000'),
+ );
+ } on Exception catch (e) {
+ return SolanaTokenApiResponse<BigInt>(
+ exception: SolanaTokenApiException(
+ 'Failed to get token supply: ${e.toString()}',
+ originalException: e,
+ ),
+ );
+ }
+ }
+
+ /// Get token account information with balance and metadata.
+ ///
+ /// Parameters:
+ /// - tokenAccountAddress: The token account address.
+ ///
+ /// Returns detailed token account information.
+ ///
+ /// Currently returns placeholder data for UI development.
+ /// TODO: Implement full RPC call when API is ready.
+ Future<SolanaTokenApiResponse<TokenAccountInfo>>
+ getTokenAccountInfo(String tokenAccountAddress) async {
+ try {
+ _checkClient();
+
+ // Return placeholder data.
+ // TODO: Implement actual RPC call using proper client methods.
+ return SolanaTokenApiResponse<TokenAccountInfo>(
+ value: TokenAccountInfo(
+ address: tokenAccountAddress,
+ owner: 'placeholder_owner',
+ mint: 'EPjFWaJUwYUoRwzwkH4H8gNB7zHW9tLT6NCKB8S4yh6h',
+ balance: BigInt.from(1000000000),
+ decimals: 6,
+ isNative: false,
+ ),
+ );
+ } on Exception catch (e) {
+ return SolanaTokenApiResponse<TokenAccountInfo>(
+ exception: SolanaTokenApiException(
+ 'Failed to get token account info: ${e.toString()}',
+ originalException: e,
+ ),
+ );
+ }
+ }
+
+ /// Find the Associated Token Account (ATA) for a wallet and mint.
+ ///
+ /// Parameters:
+ /// - ownerAddress: The wallet address.
+ /// - mint: The token mint address.
+ ///
+ /// Returns the derived ATA address.
+ String findAssociatedTokenAddress(
+ String ownerAddress,
+ String mint,
+ ) {
+ // Return a placeholder.
+ // TODO: Implement ATA derivation using Solana SDK.
+ return '';
+ }
+
+ /// Check if a wallet owns a token (has a token account for the given mint).
+ ///
+ /// Parameters:
+ /// - ownerAddress: The wallet address.
+ /// - mint: The token mint address.
+ ///
+ /// Returns true if the wallet has a token account for this mint, false otherwise.
+ /// NOTE: Currently returns placeholder data for UI development.
+ /// TODO: Implement actual RPC call to check token account ownership.
+ Future<SolanaTokenApiResponse<bool>> ownsToken(
+ String ownerAddress,
+ String mint,
+ ) async {
+ try {
+ _checkClient();
+
+ // Return placeholder.
+ // TODO: Implement actual RPC call to getTokenAccountsByOwner with mint filter.
+ return SolanaTokenApiResponse<bool>(value: false);
+ } on Exception catch (e) {
+ return SolanaTokenApiResponse<bool>(
+ exception: SolanaTokenApiException(
+ 'Failed to check token ownership: ${e.toString()}',
+ originalException: e,
+ ),
+ );
+ }
+ }
+}
diff --git a/lib/utilities/default_spl_tokens.dart b/lib/utilities/default_spl_tokens.dart
new file mode 100644
index 0000000..5d65251
--- /dev/null
+++ b/lib/utilities/default_spl_tokens.dart
@@ -0,0 +1,50 @@
+/*
+ * This file is part of Stack Wallet.
+ *
+ * Copyright (c) 2025 Cypher Stack
+ * All Rights Reserved.
+ * The code is distributed under GPLv3 license, see LICENSE file for details.
+ *
+ */
+
+import '../models/isar/models/solana/spl_token.dart';
+
+abstract class DefaultSplTokens {
+ static List<SplToken> list = [
+ SplToken(
+ address: "EPjFWaJUwYUoRwzwkH4H8gNB7zHW9tLT6NCKB8S4yh6h",
+ name: "USD Coin",
+ symbol: "USDC",
+ decimals: 6,
+ logoUri: "https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/EPjFWaJUwYUoRwzwkH4H8gNB7zHW9tLT6NCKB8S4yh6h/logo.png",
+ ),
+ SplToken(
+ address: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenEst",
+ name: "Tether",
+ symbol: "USDT",
+ decimals: 6,
+ logoUri: "https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenEst/logo.svg",
+ ),
+ SplToken(
+ address: "MangoCzJ36AjZyKwVj3VnYU4GTonjfVEnJmvvWaxLac",
+ name: "Mango",
+ symbol: "MNGO",
+ decimals: 6,
+ logoUri: "https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/MangoCzJ36AjZyKwVj3VnYU4GTonjfVEnJmvvWaxLac/logo.png",
+ ),
+ SplToken(
+ address: "SRMuApVgqbCmmp3uVrwpad5p4stLBUq3nSoSnqQQXmk",
+ name: "Serum",
+ symbol: "SRM",
+ decimals: 6,
+ logoUri: "https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/SRMuApVgqbCmmp3uVrwpad5p4stLBUq3nSoSnqQQXmk/logo.png",
+ ),
+ SplToken(
+ address: "orca8TvxvggsCKvVPXSHXDvKgJ3bNroWusDawg461mpD",
+ name: "Orca",
+ symbol: "ORCA",
+ decimals: 6,
+ logoUri: "https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/orcaEKTdK7LKz57chYcSKdBI6qrE5dS1zG4FqHWGcKc/logo.svg",
+ ),
+ ];
+}
Why this scored 19/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.