feat(spl): add sol tokens to add wallet list
What changed, and why it matters
This commit adds support for Solana SPL tokens in the wallet's 'add wallet' list. It is a feature addition that mirrors existing Ethereum token functionality, with no apparent security-relevant changes.
No security action required; review as normal feature code.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The commit introduces a new SolTokenEntity class, wires Solana token entries into the add-wallet UI, adds a route for selecting an existing Solana wallet when adding a token, and updates image loading and navigation logic. No cryptographic, authentication, authorization, input validation, or network security changes are visible in the diff.
Changed components
lib/models/add_wallet_list_entity/sub_classes/sol_token_entity.dartlib/pages/add_wallet_views/add_wallet_view/add_wallet_view.dartlib/pages/add_wallet_views/add_wallet_view/sub_widgets/coin_select_item.dartlib/pages/add_wallet_views/add_wallet_view/sub_widgets/next_button.dartlib/pages/add_wallet_views/select_wallet_for_sol_token_view.dartlib/route_generator.dartInspect captured patch +414 / −3
diff --git a/lib/models/add_wallet_list_entity/sub_classes/sol_token_entity.dart b/lib/models/add_wallet_list_entity/sub_classes/sol_token_entity.dart
new file mode 100644
index 0000000..f7a35ca
--- /dev/null
+++ b/lib/models/add_wallet_list_entity/sub_classes/sol_token_entity.dart
@@ -0,0 +1,31 @@
+/*
+ * This file is part of Stack Wallet.
+ *
+ * Copyright (c) 2023 Cypher Stack
+ * All Rights Reserved.
+ * The code is distributed under GPLv3 license, see LICENSE file for details.
+ *
+ */
+
+import '../add_wallet_list_entity.dart';
+import '../../isar/models/solana/spl_token.dart';
+import '../../../wallets/crypto_currency/crypto_currency.dart';
+
+class SolTokenEntity extends AddWalletListEntity {
+ SolTokenEntity(this.token);
+
+ final SplToken token;
+
+ @override
+ CryptoCurrency get cryptoCurrency => Solana(CryptoCurrencyNetwork.main);
+
+ @override
+ String get name => token.name;
+
+ @override
+ String get ticker => token.symbol;
+
+ @override
+ List<Object?> get props =>
+ [cryptoCurrency.identifier, name, ticker, token.address];
+}
diff --git a/lib/pages/add_wallet_views/add_wallet_view/add_wallet_view.dart b/lib/pages/add_wallet_views/add_wallet_view/add_wallet_view.dart
index 4d289a4..56012c8 100644
--- a/lib/pages/add_wallet_views/add_wallet_view/add_wallet_view.dart
+++ b/lib/pages/add_wallet_views/add_wallet_view/add_wallet_view.dart
@@ -20,13 +20,16 @@ import '../../../db/isar/main_db.dart';
import '../../../models/add_wallet_list_entity/add_wallet_list_entity.dart';
import '../../../models/add_wallet_list_entity/sub_classes/coin_entity.dart';
import '../../../models/add_wallet_list_entity/sub_classes/eth_token_entity.dart';
+import '../../../models/add_wallet_list_entity/sub_classes/sol_token_entity.dart';
import '../../../models/isar/models/ethereum/eth_contract.dart';
+import '../../../models/isar/models/solana/spl_token.dart';
import '../../../pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart';
import '../../../providers/providers.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/crypto_currency/crypto_currency.dart';
@@ -41,6 +44,7 @@ import '../../../widgets/rounded_white_container.dart';
import '../../../widgets/stack_text_field.dart';
import '../../../widgets/textfield_icon_button.dart';
import '../add_token_view/add_custom_token_view.dart';
+import '../add_token_view/add_custom_solana_token_view.dart';
import '../add_token_view/sub_widgets/add_custom_token_selector.dart';
import 'sub_widgets/add_wallet_text.dart';
import 'sub_widgets/expanding_sub_list_item.dart';
@@ -68,6 +72,7 @@ class _AddWalletViewState extends ConsumerState<AddWalletView> {
final List<AddWalletListEntity> coinEntities = [];
final List<AddWalletListEntity> coinTestnetEntities = [];
final List<EthTokenEntity> tokenEntities = [];
+ final List<SolTokenEntity> solTokenEntities = [];
final bool isDesktop = Util.isDesktop;
@@ -84,6 +89,8 @@ class _AddWalletViewState extends ConsumerState<AddWalletView> {
e.name.toLowerCase().contains(lowercaseTerm) ||
e.cryptoCurrency.identifier.toLowerCase().contains(lowercaseTerm) ||
(e is EthTokenEntity &&
+ e.token.address.toLowerCase().contains(lowercaseTerm)) ||
+ (e is SolTokenEntity &&
e.token.address.toLowerCase().contains(lowercaseTerm)),
);
}
@@ -125,6 +132,38 @@ class _AddWalletViewState extends ConsumerState<AddWalletView> {
}
}
+ Future<void> _addSolToken() async {
+ SplToken? token;
+ if (isDesktop) {
+ token = await showDialog(
+ context: context,
+ builder:
+ (context) => const DesktopDialog(
+ maxWidth: 580,
+ maxHeight: 500,
+ child: AddCustomSolanaTokenView(),
+ ),
+ );
+ } else {
+ token = await Navigator.of(
+ context,
+ ).pushNamed(AddCustomSolanaTokenView.routeName);
+ }
+
+ if (token != null) {
+ await MainDB.instance.putSplToken(token);
+ if (mounted) {
+ setState(() {
+ if (solTokenEntities
+ .where((e) => e.token.address == token!.address)
+ .isEmpty) {
+ solTokenEntities.add(SolTokenEntity(token!));
+ }
+ });
+ }
+ }
+ }
+
@override
void initState() {
_searchFieldController = TextEditingController();
@@ -153,6 +192,20 @@ class _AddWalletViewState extends ConsumerState<AddWalletView> {
tokenEntities.addAll(contracts.map((e) => EthTokenEntity(e)));
}
+ if (AppConfig.coins.whereType<Solana>().isNotEmpty) {
+ // Add default tokens.
+ final defaultTokenAddresses = DefaultSplTokens.list.map((e) => e.address).toSet();
+ solTokenEntities.addAll(DefaultSplTokens.list.map((e) => SolTokenEntity(e)));
+
+ // Add custom tokens from database.
+ final allDatabaseTokens = MainDB.instance.getSplTokens().findAllSync();
+ for (final token in allDatabaseTokens) {
+ if (!defaultTokenAddresses.contains(token.address)) {
+ solTokenEntities.add(SolTokenEntity(token));
+ }
+ }
+ }
+
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
ref.refresh(addWalletSelectedEntityStateProvider);
@@ -296,7 +349,7 @@ class _AddWalletViewState extends ConsumerState<AddWalletView> {
),
if (tokenEntities.isNotEmpty)
ExpandingSubListItem(
- title: "Tokens",
+ title: "Ethereum tokens",
entities: filter(_searchTerm, tokenEntities),
initialState: ExpandableState.expanded,
animationDurationMultiplier: 0.5,
@@ -304,6 +357,16 @@ class _AddWalletViewState extends ConsumerState<AddWalletView> {
addFunction: _addToken,
),
),
+ if (solTokenEntities.isNotEmpty)
+ ExpandingSubListItem(
+ title: "Solana tokens",
+ entities: filter(_searchTerm, solTokenEntities),
+ initialState: ExpandableState.expanded,
+ animationDurationMultiplier: 0.5,
+ trailing: AddCustomTokenSelector(
+ addFunction: _addSolToken,
+ ),
+ ),
],
),
),
@@ -427,10 +490,16 @@ class _AddWalletViewState extends ConsumerState<AddWalletView> {
),
if (tokenEntities.isNotEmpty)
ExpandingSubListItem(
- title: "Tokens",
+ title: "Ethereum tokens",
entities: filter(_searchTerm, tokenEntities),
initialState: ExpandableState.expanded,
),
+ if (solTokenEntities.isNotEmpty)
+ ExpandingSubListItem(
+ title: "Solana tokens",
+ entities: filter(_searchTerm, solTokenEntities),
+ initialState: ExpandableState.expanded,
+ ),
],
),
),
diff --git a/lib/pages/add_wallet_views/add_wallet_view/sub_widgets/coin_select_item.dart b/lib/pages/add_wallet_views/add_wallet_view/sub_widgets/coin_select_item.dart
index 72adf39..4f860c1 100644
--- a/lib/pages/add_wallet_views/add_wallet_view/sub_widgets/coin_select_item.dart
+++ b/lib/pages/add_wallet_views/add_wallet_view/sub_widgets/coin_select_item.dart
@@ -17,6 +17,7 @@ import 'package:isar_community/isar.dart';
import '../../../../models/add_wallet_list_entity/add_wallet_list_entity.dart';
import '../../../../models/add_wallet_list_entity/sub_classes/eth_token_entity.dart';
+import '../../../../models/add_wallet_list_entity/sub_classes/sol_token_entity.dart';
import '../../../../models/isar/exchange_cache/currency.dart';
import '../../../../providers/providers.dart';
import '../../../../services/exchange/change_now/change_now_exchange.dart';
@@ -27,6 +28,7 @@ import '../../../../utilities/assets.dart';
import '../../../../utilities/constants.dart';
import '../../../../utilities/text_styles.dart';
import '../../../../utilities/util.dart';
+import '../../../../widgets/app_icon.dart';
class CoinSelectItem extends ConsumerStatefulWidget {
const CoinSelectItem({super.key, required this.entity});
@@ -69,6 +71,39 @@ class _CoinSelectItemState extends ConsumerState<CoinSelectItem> {
});
}
});
+ } else if (widget.entity is SolTokenEntity) {
+ final solToken = (widget.entity as SolTokenEntity).token;
+
+ ExchangeDataLoadingService.instance.isar.then((isar) async {
+ final currency =
+ await isar.currencies
+ .where()
+ .exchangeNameEqualTo(ChangeNowExchange.exchangeName)
+ .filter()
+ .tokenContractEqualTo(
+ solToken.address,
+ caseSensitive: false,
+ )
+ .and()
+ .imageIsNotEmpty()
+ .findFirst();
+
+ if (mounted) {
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (mounted) {
+ setState(() {
+ // Use exchange cache image if available, otherwise use logoUri if it's a PNG.
+ String? fallbackUri;
+ if (solToken.logoUri != null &&
+ solToken.logoUri!.endsWith('.png')) {
+ fallbackUri = solToken.logoUri;
+ }
+ tokenImageUri = currency?.image ?? fallbackUri;
+ });
+ }
+ });
+ }
+ });
}
}
@@ -108,7 +143,12 @@ class _CoinSelectItemState extends ConsumerState<CoinSelectItem> {
child: Row(
children: [
tokenImageUri != null
- ? SvgPicture.network(tokenImageUri!, width: 26, height: 26)
+ ? SvgPicture.network(
+ tokenImageUri!,
+ width: 26,
+ height: 26,
+ placeholderBuilder: (_) => AppIcon(width: 26, height: 26),
+ )
: SvgPicture.file(
File(
ref.watch(coinIconProvider(widget.entity.cryptoCurrency)),
diff --git a/lib/pages/add_wallet_views/add_wallet_view/sub_widgets/next_button.dart b/lib/pages/add_wallet_views/add_wallet_view/sub_widgets/next_button.dart
index 4447cc7..f88b491 100644
--- a/lib/pages/add_wallet_views/add_wallet_view/sub_widgets/next_button.dart
+++ b/lib/pages/add_wallet_views/add_wallet_view/sub_widgets/next_button.dart
@@ -11,8 +11,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../models/add_wallet_list_entity/sub_classes/eth_token_entity.dart';
+import '../../../../models/add_wallet_list_entity/sub_classes/sol_token_entity.dart';
import '../../create_or_restore_wallet_view/create_or_restore_wallet_view.dart';
import '../../select_wallet_for_token_view.dart';
+import '../../select_wallet_for_sol_token_view.dart';
import '../../../../providers/providers.dart';
import '../../../../themes/stack_colors.dart';
import '../../../../utilities/text_styles.dart';
@@ -42,6 +44,11 @@ class AddWalletNextButton extends ConsumerWidget {
SelectWalletForTokenView.routeName,
arguments: selectedCoin,
);
+ } else if (selectedCoin is SolTokenEntity) {
+ Navigator.of(context).pushNamed(
+ SelectWalletForSolTokenView.routeName,
+ arguments: selectedCoin,
+ );
} else {
Navigator.of(context).pushNamed(
CreateOrRestoreWalletView.routeName,
diff --git a/lib/pages/add_wallet_views/select_wallet_for_sol_token_view.dart b/lib/pages/add_wallet_views/select_wallet_for_sol_token_view.dart
new file mode 100644
index 0000000..d5c012b
--- /dev/null
+++ b/lib/pages/add_wallet_views/select_wallet_for_sol_token_view.dart
@@ -0,0 +1,252 @@
+/*
+ * This file is part of Stack Wallet.
+ *
+ * Copyright (c) 2023 Cypher Stack
+ * All Rights Reserved.
+ * The code is distributed under GPLv3 license, see LICENSE file for details.
+ *
+ */
+
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import '../../models/add_wallet_list_entity/sub_classes/coin_entity.dart';
+import '../../models/add_wallet_list_entity/sub_classes/sol_token_entity.dart';
+import 'add_token_view/edit_wallet_tokens_view.dart';
+import 'create_or_restore_wallet_view/create_or_restore_wallet_view.dart';
+import 'verify_recovery_phrase_view/verify_recovery_phrase_view.dart';
+import '../../themes/stack_colors.dart';
+import '../../utilities/text_styles.dart';
+import '../../utilities/util.dart';
+import '../../wallets/isar/providers/all_wallets_info_provider.dart';
+import '../../wallets/isar/providers/wallet_info_provider.dart';
+import '../../widgets/background.dart';
+import '../../widgets/conditional_parent.dart';
+import '../../widgets/custom_buttons/app_bar_icon_button.dart';
+import '../../widgets/desktop/desktop_app_bar.dart';
+import '../../widgets/desktop/desktop_scaffold.dart';
+import '../../widgets/desktop/primary_button.dart';
+import '../../widgets/eth_wallet_radio.dart';
+import '../../widgets/rounded_container.dart';
+import '../../widgets/rounded_white_container.dart';
+import '../../widgets/wallet_info_row/wallet_info_row.dart';
+import 'package:tuple/tuple.dart';
+
+final newSolWalletTriggerTempUntilHiveCompletelyDeleted =
+ StateProvider((ref) => false);
+
+class SelectWalletForSolTokenView extends ConsumerStatefulWidget {
+ const SelectWalletForSolTokenView({
+ super.key,
+ required this.entity,
+ });
+
+ static const String routeName = "/selectWalletForSolTokenView";
+
+ final SolTokenEntity entity;
+
+ @override
+ ConsumerState<SelectWalletForSolTokenView> createState() =>
+ _SelectWalletForSolTokenViewState();
+}
+
+class _SelectWalletForSolTokenViewState
+ extends ConsumerState<SelectWalletForSolTokenView> {
+ final isDesktop = Util.isDesktop;
+
+ String? _selectedWalletId;
+
+ void _onContinue() {
+ Navigator.of(context).pushNamed(
+ EditWalletTokensView.routeName,
+ arguments: Tuple2(
+ _selectedWalletId!,
+ [widget.entity.token.address],
+ ),
+ );
+ }
+
+ void _onAddNewSolWallet() {
+ ref.read(newSolWalletTriggerTempUntilHiveCompletelyDeleted.notifier).state = true;
+ Navigator.of(context).pushNamed(
+ CreateOrRestoreWalletView.routeName,
+ arguments: CoinEntity(widget.entity.cryptoCurrency),
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final solWalletInfos = ref
+ .watch(pAllWalletsInfo)
+ .where((e) => e.coin == widget.entity.cryptoCurrency)
+ .toList();
+
+ final _hasSolWallets = solWalletInfos.isNotEmpty;
+
+ final List<String> solWalletIds = [];
+
+ for (final walletId in solWalletInfos.map((e) => e.walletId).toList()) {
+ final walletTokens = ref.read(pWalletTokenAddresses(walletId));
+ if (!walletTokens.contains(widget.entity.token.address)) {
+ solWalletIds.add(walletId);
+ }
+ }
+
+ return WillPopScope(
+ onWillPop: () async {
+ ref.read(newSolWalletTriggerTempUntilHiveCompletelyDeleted.notifier).state = false;
+ return true;
+ },
+ child: ConditionalParent(
+ condition: !isDesktop,
+ builder: (child) => Background(
+ child: Scaffold(
+ backgroundColor:
+ Theme.of(context).extension<StackColors>()!.background,
+ appBar: AppBar(
+ leading: AppBarBackButton(
+ onPressed: () {
+ Navigator.of(context).pop();
+ },
+ ),
+ ),
+ body: SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: child,
+ ),
+ ),
+ ),
+ ),
+ child: ConditionalParent(
+ condition: isDesktop,
+ builder: (child) => DesktopScaffold(
+ appBar: const DesktopAppBar(
+ isCompactHeight: false,
+ leading: AppBarBackButton(),
+ ),
+ body: SizedBox(
+ width: 500,
+ child: child,
+ ),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ if (isDesktop)
+ const SizedBox(
+ height: 24,
+ ),
+ Text(
+ "Select Solana wallet",
+ textAlign: TextAlign.center,
+ style: isDesktop
+ ? STextStyles.desktopH2(context)
+ : STextStyles.pageTitleH1(context),
+ ),
+ SizedBox(
+ height: isDesktop ? 16 : 8,
+ ),
+ Text(
+ "You are adding a Solana token.",
+ textAlign: TextAlign.center,
+ style: isDesktop
+ ? STextStyles.desktopSubtitleH2(context)
+ : STextStyles.subtitle(context),
+ ),
+ const SizedBox(
+ height: 8,
+ ),
+ Text(
+ "You must choose a Solana wallet in order to use ${widget.entity.name}",
+ textAlign: TextAlign.center,
+ style: isDesktop
+ ? STextStyles.desktopSubtitleH2(context)
+ : STextStyles.subtitle(context),
+ ),
+ SizedBox(
+ height: isDesktop ? 60 : 16,
+ ),
+ solWalletIds.isEmpty
+ ? RoundedWhiteContainer(
+ padding: EdgeInsets.all(isDesktop ? 16 : 12),
+ child: Text(
+ _hasSolWallets
+ ? "All current Solana wallets already have ${widget.entity.name}"
+ : "You do not have any Solana wallets",
+ style: isDesktop
+ ? STextStyles.desktopSubtitleH2(context)
+ : STextStyles.label(context),
+ ),
+ )
+ : ConditionalParent(
+ condition: !isDesktop,
+ builder: (child) => Expanded(
+ child: Column(
+ children: [
+ RoundedWhiteContainer(
+ padding: const EdgeInsets.all(8),
+ child: child,
+ ),
+ ],
+ ),
+ ),
+ child: ListView.separated(
+ itemCount: solWalletIds.length,
+ shrinkWrap: true,
+ separatorBuilder: (_, __) => SizedBox(
+ height: isDesktop ? 12 : 6,
+ ),
+ itemBuilder: (_, index) {
+ return RoundedContainer(
+ padding: EdgeInsets.all(isDesktop ? 16 : 8),
+ onPressed: () {
+ setState(() {
+ _selectedWalletId = solWalletIds[index];
+ });
+ },
+ color: isDesktop
+ ? Theme.of(context)
+ .extension<StackColors>()!
+ .popupBG
+ : _selectedWalletId == solWalletIds[index]
+ ? Theme.of(context)
+ .extension<StackColors>()!
+ .highlight
+ : Colors.transparent,
+ child: isDesktop
+ ? EthWalletRadio(
+ walletId: solWalletIds[index],
+ selectedWalletId: _selectedWalletId,
+ )
+ : WalletInfoRow(
+ walletId: solWalletIds[index],
+ ),
+ );
+ },
+ ),
+ ),
+ if (solWalletIds.isEmpty || isDesktop)
+ const SizedBox(
+ height: 16,
+ ),
+ if (isDesktop)
+ const SizedBox(
+ height: 16,
+ ),
+ solWalletIds.isEmpty
+ ? PrimaryButton(
+ label: "Add new Solana wallet",
+ onPressed: _onAddNewSolWallet,
+ )
+ : PrimaryButton(
+ label: "Continue",
+ enabled: _selectedWalletId != null,
+ onPressed: _onContinue,
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/route_generator.dart b/lib/route_generator.dart
index 1a91b27..35bf76b 100644
--- a/lib/route_generator.dart
+++ b/lib/route_generator.dart
@@ -17,6 +17,7 @@ import 'app_config.dart';
import 'db/drift/database.dart';
import 'models/add_wallet_list_entity/add_wallet_list_entity.dart';
import 'models/add_wallet_list_entity/sub_classes/eth_token_entity.dart';
+import 'models/add_wallet_list_entity/sub_classes/sol_token_entity.dart';
import 'models/buy/response_objects/quote.dart';
import 'models/exchange/incomplete_exchange.dart';
import 'models/exchange/response_objects/trade.dart';
@@ -44,6 +45,7 @@ import 'pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_
import 'pages/add_wallet_views/restore_wallet_view/restore_view_only_wallet_view.dart';
import 'pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart';
import 'pages/add_wallet_views/select_wallet_for_token_view.dart';
+import 'pages/add_wallet_views/select_wallet_for_sol_token_view.dart';
import 'pages/add_wallet_views/verify_recovery_phrase_view/verify_recovery_phrase_view.dart';
import 'pages/address_book_views/address_book_view.dart';
import 'pages/address_book_views/subviews/add_address_book_entry_view.dart';
@@ -396,6 +398,16 @@ class RouteGenerator {
}
return _routeError("${settings.name} invalid args: ${args.toString()}");
+ case SelectWalletForSolTokenView.routeName:
+ if (args is SolTokenEntity) {
+ return getRoute(
+ shouldUseMaterialRoute: useMaterialPageRoute,
+ builder: (_) => SelectWalletForSolTokenView(entity: args),
+ settings: RouteSettings(name: settings.name),
+ );
+ }
+ return _routeError("${settings.name} invalid args: ${args.toString()}");
+
case AddCustomTokenView.routeName:
return getRoute(
shouldUseMaterialRoute: useMaterialPageRoute,
Why this scored 15/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.