feat(spl): custom tokens with validation and error handling
What changed, and why it matters
This commit adds a new screen that lets users manually add custom Solana SPL tokens to their Stack Wallet. It checks the mint address format, tries to fetch token details from an API, and lets the user type in details if the API has no record. The change also replaces the old fixed list of Solana tokens with this custom-add flow. There is no clear security bug in the diff, but the new code trusts user-supplied token metadata and does not verify on-chain that the token really exists or that the wallet holds it, which could let a user be tricked into adding a fake or misleading token.
Treat this as a feature review rather than a confirmed vulnerability. If the project wants to reduce spoofing risk, add on-chain validation that the mint address is an initialized SPL Token mint, optionally verify the wallet's associated token account, and warn users when metadata is user-supplied. Also consider requiring a known source (e.g., token-list registry) before allowing a token to be added, or visually distinguishing custom/unverified tokens in the UI.
Security signals we found
User-controlled token metadata accepted when API returns no data
Mint address format validation only; no on-chain mint account verification shown
No shown verification that token is a real SPL token or that wallet holds associated token account
Potential UI spoofing: a malicious actor could instruct a user to add a fake token with a legitimate-looking name/symbol
Custom token list merged with default token list, so fake tokens appear alongside real ones
Debug logging of token ownership check present but not shown to block addition
Evidence from the diff
The patch introduces AddCustomSolanaTokenView, a Flutter UI for adding arbitrary Solana SPL tokens by mint address. It calls SolanaTokenAPI.isValidSolanaMintAddress for format validation and fetchTokenMetadataByMint for metadata lookup. If metadata is missing, it enables manual entry of name/symbol/decimals and allows the token to be added. The parent view (edit_wallet_tokens_view.dart) now routes SolanaWallet users to this screen instead of showing a curated DefaultSplTokens list, and persists custom mint addresses via updateSolanaCustomTokenMintAddresses. The diff does not show on-chain validation of the mint account, token program ownership, or balance/ATA checks; it relies on an external metadata API and user input. The existing ownership-check logging block remains but is not shown to gate addition.
Changed components
lib/pages/add_wallet_views/add_token_view/add_custom_solana_token_view.dartlib/pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dartSolanaWallet token management UISolanaTokenAPI clientSPL token metadata persistence (MainDB / Isar)Inspect captured patch +596 / −78
diff --git a/lib/pages/add_wallet_views/add_token_view/add_custom_solana_token_view.dart b/lib/pages/add_wallet_views/add_token_view/add_custom_solana_token_view.dart
new file mode 100644
index 0000000..9c3b2f9
--- /dev/null
+++ b/lib/pages/add_wallet_views/add_token_view/add_custom_solana_token_view.dart
@@ -0,0 +1,524 @@
+/*
+ * 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.
+ * Generated by Cypher Stack on 2023-05-26
+ *
+ */
+
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../../../models/isar/models/solana/spl_token.dart';
+import '../../../services/solana/solana_token_api.dart';
+import '../../../themes/stack_colors.dart';
+import '../../../utilities/text_styles.dart';
+import '../../../utilities/util.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_dialog.dart';
+import '../../../widgets/desktop/desktop_dialog_close_button.dart';
+import '../../../widgets/desktop/primary_button.dart';
+import '../../../widgets/desktop/secondary_button.dart';
+import '../../../widgets/stack_dialog.dart';
+
+class AddCustomSolanaTokenView extends ConsumerStatefulWidget {
+ const AddCustomSolanaTokenView({
+ super.key,
+ this.walletId,
+ });
+
+ static const routeName = "/addCustomSolanaToken";
+
+ final String? walletId;
+
+ @override
+ ConsumerState<AddCustomSolanaTokenView> createState() =>
+ _AddCustomSolanaTokenViewState();
+}
+
+class _AddCustomSolanaTokenViewState
+ extends ConsumerState<AddCustomSolanaTokenView> {
+ final isDesktop = Util.isDesktop;
+
+ final mintController = TextEditingController();
+ final nameController = TextEditingController();
+ final symbolController = TextEditingController();
+ final decimalsController = TextEditingController();
+
+ bool enableSubFields = false;
+ bool addTokenButtonEnabled = false;
+
+ SplToken? currentToken;
+
+ Future<void> _searchTokenMetadata() async {
+ debugPrint('[ADD_CUSTOM_SOLANA_TOKEN] Search button pressed');
+
+ // Validate mint address format first.
+ final tokenApi = SolanaTokenAPI();
+ final isValid = tokenApi.isValidSolanaMintAddress(
+ mintController.text.trim(),
+ );
+
+ debugPrint('[ADD_CUSTOM_SOLANA_TOKEN] Mint address valid: $isValid');
+
+ // Check if token is already in the wallet.
+ if (widget.walletId != null) {
+ final walletInfo = ref.watch(pWalletInfo(widget.walletId!));
+ final allTokenMints = <String>{
+ ...walletInfo.solanaTokenMintAddresses,
+ ...walletInfo.solanaCustomTokenMintAddresses,
+ };
+
+ if (allTokenMints.contains(mintController.text.trim())) {
+ debugPrint('[ADD_CUSTOM_SOLANA_TOKEN] Token already in wallet');
+ setState(() {
+ addTokenButtonEnabled = false;
+ });
+ // Show error dialog for duplicate token.
+ if (mounted) {
+ unawaited(
+ showDialog<void>(
+ context: context,
+ builder: (dialogContext) => ConditionalParent(
+ condition: Util.isDesktop,
+ builder: (child) => DesktopDialog(
+ maxWidth: 500,
+ maxHeight: double.infinity,
+ child: Padding(
+ padding: const EdgeInsets.all(32),
+ child: child,
+ ),
+ ),
+ child: ConditionalParent(
+ condition: !Util.isDesktop,
+ builder: (child) => StackDialogBase(child: child),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ "Token Already Added",
+ style: STextStyles.pageTitleH2(context),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ "This token is already in your wallet.",
+ style: STextStyles.smallMed14(context),
+ ),
+ const SizedBox(height: 20),
+ Row(
+ children: [
+ const Spacer(),
+ Expanded(
+ child: PrimaryButton(
+ label: "OK",
+ onPressed: () => Navigator.of(dialogContext).pop(),
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+ return;
+ }
+ }
+
+ if (!isValid) {
+ setState(() {
+ addTokenButtonEnabled = false;
+ });
+ // Show error dialog for invalid address.
+ if (mounted) {
+ unawaited(
+ showDialog<void>(
+ context: context,
+ builder: (dialogContext) => ConditionalParent(
+ condition: Util.isDesktop,
+ builder: (child) => DesktopDialog(
+ maxWidth: 500,
+ maxHeight: double.infinity,
+ child: Padding(
+ padding: const EdgeInsets.all(32),
+ child: child,
+ ),
+ ),
+ child: ConditionalParent(
+ condition: !Util.isDesktop,
+ builder: (child) => StackDialogBase(child: child),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ "Invalid Mint Address",
+ style: STextStyles.pageTitleH2(context),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ "Please enter a valid Solana SPL token mint address (base58 encoded, ~44 characters).",
+ style: STextStyles.smallMed14(context),
+ ),
+ const SizedBox(height: 20),
+ Row(
+ children: [
+ const Spacer(),
+ Expanded(
+ child: PrimaryButton(
+ label: "OK",
+ onPressed: () => Navigator.of(dialogContext).pop(),
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+ return;
+ }
+
+ // Fetch token metadata.
+ debugPrint('[ADD_CUSTOM_SOLANA_TOKEN] Fetching metadata for mint: ${mintController.text.trim()}');
+ final response =
+ await tokenApi.fetchTokenMetadataByMint(mintController.text.trim());
+
+ if (!mounted) return;
+
+ debugPrint('[ADD_CUSTOM_SOLANA_TOKEN] Metadata response: ${response.value}');
+
+ if (response.value != null && response.value!.isNotEmpty) {
+ final metadata = response.value!;
+ currentToken = SplToken(
+ address: mintController.text.trim(),
+ name: metadata['name'] as String? ?? 'Unknown Token',
+ symbol: metadata['symbol'] as String? ?? '???',
+ decimals: metadata['decimals'] as int? ?? 6,
+ logoUri: metadata['logoUri'] as String?,
+ );
+
+ nameController.text = currentToken!.name;
+ symbolController.text = currentToken!.symbol;
+ decimalsController.text = currentToken!.decimals.toString();
+
+ // Disable editing when we have metadata.
+ setState(() {
+ enableSubFields = false;
+ addTokenButtonEnabled = currentToken != null;
+ });
+ debugPrint('[ADD_CUSTOM_SOLANA_TOKEN] Metadata found, fields populated');
+ } else {
+ // Token not found, allow user to manually enter details.
+ debugPrint('[ADD_CUSTOM_SOLANA_TOKEN] Metadata not found, enabling manual entry');
+ nameController.text = "";
+ symbolController.text = "";
+ decimalsController.text = "";
+
+ // Enable fields for manual entry and allow user to create token with custom values.
+ setState(() {
+ enableSubFields = true;
+ currentToken = SplToken(
+ address: mintController.text.trim(),
+ name: '',
+ symbol: '',
+ decimals: 6,
+ logoUri: null,
+ );
+ // Allow adding token once mint is validated.
+ addTokenButtonEnabled = true;
+ });
+
+ // Show dialog for manual entry & alert the user they need to enter details manually.
+ if (mounted) {
+ unawaited(
+ showDialog<void>(
+ context: context,
+ builder: (dialogContext) => ConditionalParent(
+ condition: Util.isDesktop,
+ builder: (child) => DesktopDialog(
+ maxWidth: 500,
+ maxHeight: double.infinity,
+ child: Padding(
+ padding: const EdgeInsets.all(32),
+ child: child,
+ ),
+ ),
+ child: ConditionalParent(
+ condition: !Util.isDesktop,
+ builder: (child) => StackDialogBase(child: child),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ "Metadata Not Found",
+ style: STextStyles.pageTitleH2(context),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ "Could not fetch token metadata. Please enter the token details manually below.",
+ style: STextStyles.smallMed14(context),
+ ),
+ const SizedBox(height: 20),
+ Row(
+ children: [
+ const Spacer(),
+ Expanded(
+ child: PrimaryButton(
+ label: "OK",
+ onPressed: () => Navigator.of(dialogContext).pop(),
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return 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.only(
+ top: 10,
+ left: 16,
+ right: 16,
+ bottom: 16,
+ ),
+ child: child,
+ ),
+ ),
+ ),
+ ),
+ child: ConditionalParent(
+ condition: isDesktop,
+ builder: (child) => Column(
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(left: 32),
+ child: Text(
+ "Add custom SPL token",
+ style: STextStyles.desktopH3(context),
+ ),
+ ),
+ const DesktopDialogCloseButton(),
+ ],
+ ),
+ Flexible(
+ child: Padding(
+ padding: const EdgeInsets.only(
+ left: 32,
+ right: 32,
+ bottom: 32,
+ top: 16,
+ ),
+ child: child,
+ ),
+ ),
+ ],
+ ),
+ child: Column(
+ children: [
+ if (!isDesktop)
+ Text(
+ "Add custom SPL token",
+ style: STextStyles.pageTitleH1(context),
+ ),
+ if (!isDesktop) const SizedBox(height: 16),
+ TextField(
+ autocorrect: !isDesktop,
+ enableSuggestions: !isDesktop,
+ controller: mintController,
+ style: STextStyles.field(context),
+ decoration: InputDecoration(
+ hintText: "SPL token mint address",
+ hintStyle: STextStyles.fieldLabel(context),
+ ),
+ ),
+ SizedBox(height: isDesktop ? 16 : 8),
+ PrimaryButton(
+ label: "Search",
+ onPressed: _searchTokenMetadata,
+ ),
+ SizedBox(height: isDesktop ? 16 : 8),
+ TextField(
+ enabled: enableSubFields,
+ autocorrect: !isDesktop,
+ enableSuggestions: !isDesktop,
+ controller: nameController,
+ style: STextStyles.field(context),
+ decoration: InputDecoration(
+ hintText: "Token name",
+ hintStyle: STextStyles.fieldLabel(context),
+ ),
+ ),
+ SizedBox(height: isDesktop ? 16 : 8),
+ if (isDesktop)
+ Row(
+ children: [
+ Expanded(
+ child: TextField(
+ enabled: enableSubFields,
+ autocorrect: !isDesktop,
+ enableSuggestions: !isDesktop,
+ controller: symbolController,
+ style: STextStyles.field(context),
+ decoration: InputDecoration(
+ hintText: "Ticker",
+ hintStyle: STextStyles.fieldLabel(context),
+ ),
+ ),
+ ),
+ const SizedBox(width: 16),
+ Expanded(
+ child: TextField(
+ enabled: enableSubFields,
+ autocorrect: !isDesktop,
+ enableSuggestions: !isDesktop,
+ controller: decimalsController,
+ style: STextStyles.field(context),
+ inputFormatters: [
+ TextInputFormatter.withFunction(
+ (oldValue, newValue) =>
+ RegExp(r'^([0-9]*)$').hasMatch(newValue.text)
+ ? newValue
+ : oldValue,
+ ),
+ ],
+ keyboardType: const TextInputType.numberWithOptions(
+ signed: false,
+ decimal: false,
+ ),
+ decoration: InputDecoration(
+ hintText: "Decimals",
+ hintStyle: STextStyles.fieldLabel(context),
+ ),
+ ),
+ ),
+ ],
+ ),
+ if (!isDesktop)
+ TextField(
+ enabled: enableSubFields,
+ autocorrect: !isDesktop,
+ enableSuggestions: !isDesktop,
+ controller: symbolController,
+ style: STextStyles.field(context),
+ decoration: InputDecoration(
+ hintText: "Ticker",
+ hintStyle: STextStyles.fieldLabel(context),
+ ),
+ ),
+ if (!isDesktop) const SizedBox(height: 8),
+ if (!isDesktop)
+ TextField(
+ enabled: enableSubFields,
+ autocorrect: !isDesktop,
+ enableSuggestions: !isDesktop,
+ controller: decimalsController,
+ style: STextStyles.field(context),
+ inputFormatters: [
+ TextInputFormatter.withFunction(
+ (oldValue, newValue) =>
+ RegExp(r'^([0-9]*)$').hasMatch(newValue.text)
+ ? newValue
+ : oldValue,
+ ),
+ ],
+ keyboardType: const TextInputType.numberWithOptions(
+ signed: false,
+ decimal: false,
+ ),
+ decoration: InputDecoration(
+ hintText: "Decimals",
+ hintStyle: STextStyles.fieldLabel(context),
+ ),
+ ),
+ const SizedBox(height: 16),
+ const Spacer(),
+ Row(
+ children: [
+ if (isDesktop)
+ Expanded(
+ child: SecondaryButton(
+ label: "Cancel",
+ onPressed: Navigator.of(context).pop,
+ ),
+ ),
+ if (isDesktop) const SizedBox(width: 16),
+ Expanded(
+ child: PrimaryButton(
+ label: "Add token",
+ enabled: addTokenButtonEnabled,
+ onPressed: () {
+ // Update currentToken with user-entered values.
+ if (currentToken != null) {
+ final finalToken = currentToken!.copyWith(
+ name: nameController.text.isNotEmpty
+ ? nameController.text
+ : currentToken!.name,
+ symbol: symbolController.text.isNotEmpty
+ ? symbolController.text
+ : currentToken!.symbol,
+ decimals: decimalsController.text.isNotEmpty
+ ? int.tryParse(decimalsController.text) ??
+ currentToken!.decimals
+ : currentToken!.decimals,
+ );
+ Navigator.of(context).pop(finalToken);
+ }
+ },
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ @override
+ void dispose() {
+ mintController.dispose();
+ nameController.dispose();
+ symbolController.dispose();
+ decimalsController.dispose();
+ super.dispose();
+ }
+}
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 e86182b..6197a33 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
@@ -48,6 +48,7 @@ import '../../../widgets/stack_text_field.dart';
import '../../../widgets/textfield_icon_button.dart';
import '../../home_view/home_view.dart';
import 'add_custom_token_view.dart';
+import 'add_custom_solana_token_view.dart';
import 'sub_widgets/add_token_list.dart';
import 'sub_widgets/add_token_list_element.dart';
import 'sub_widgets/add_token_text.dart';
@@ -116,11 +117,29 @@ class _EditWalletTokensViewState extends ConsumerState<EditWalletTokensView> {
else if (wallet is SolanaWallet) {
// Get WalletInfo and update Solana token mint addresses.
final walletInfo = wallet.info;
+
+ // Combine default tokens with custom tokens.
+ final allSelectedTokens = selectedTokens.toSet();
+ // Add any existing custom tokens that should be preserved.
+ allSelectedTokens.addAll(walletInfo.solanaCustomTokenMintAddresses);
+
await walletInfo.updateSolanaTokenMintAddresses(
newMintAddresses: selectedTokens.toSet(),
isar: MainDB.instance.isar,
);
+ // Update custom tokens if any.
+ final customTokens = allSelectedTokens.where(
+ (mint) => !selectedTokens.contains(mint),
+ ).toSet();
+
+ if (customTokens.isNotEmpty) {
+ await walletInfo.updateSolanaCustomTokenMintAddresses(
+ newMintAddresses: customTokens,
+ isar: MainDB.instance.isar,
+ );
+ }
+
// Log selected tokens and verify ownership.
debugPrint('===== SOLANA TOKEN OWNERSHIP CHECK =====');
debugPrint('Wallet: ${walletInfo.name}');
@@ -143,7 +162,7 @@ class _EditWalletTokensViewState extends ConsumerState<EditWalletTokensView> {
final tokenEntity = tokenEntities.firstWhere(
(e) => e.token.address == mintAddress,
orElse: () => AddTokenListElementData(
- // Fallback contract with just the address
+ // Fallback contract with just the address.
EthContract(
address: mintAddress,
name: 'Unknown Token',
@@ -228,83 +247,8 @@ class _EditWalletTokensViewState extends ConsumerState<EditWalletTokensView> {
final wallet = ref.read(pWallets).getWallet(widget.walletId);
if (wallet is SolanaWallet) {
- // For Solana wallets, show available SPL tokens to add.
- final availableTokens = DefaultSplTokens.list
- .where((t) => !tokenEntities.any((e) => e.token.address == t.address))
- .toList();
-
- if (availableTokens.isEmpty) {
- debugPrint("All available Solana tokens have been added");
- return;
- }
-
- // Show a simple selection dialog for Solana tokens.
- if (isDesktop) {
- // For desktop, you could show a dialog with token list.
- // For now, just add the first available token.
- if (availableTokens.isNotEmpty) {
- final token = availableTokens.first;
- await MainDB.instance.putSplToken(token);
- unawaited(ref.read(priceAnd24hChangeNotifierProvider).updatePrice());
- if (mounted) {
- setState(() {
- tokenEntities.add(
- AddTokenListElementData(token)..selected = true,
- );
- tokenEntities.sort((a, b) => a.token.name.compareTo(b.token.name));
- });
- }
- }
- } else {
- // For mobile, show a simple bottom sheet.
- if (mounted) {
- final selected = await showModalBottomSheet<dynamic>(
- context: context,
- builder: (context) => Container(
- color: Theme.of(context).extension<StackColors>()!.popupBG,
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- Padding(
- padding: const EdgeInsets.all(16),
- child: Text(
- "Select Token to Add",
- style: STextStyles.titleBold12(context),
- ),
- ),
- Expanded(
- child: ListView.builder(
- itemCount: availableTokens.length,
- itemBuilder: (context, index) {
- final token = availableTokens[index];
- return ListTile(
- title: Text(token.name),
- subtitle: Text(token.symbol),
- onTap: () => Navigator.pop(context, token),
- );
- },
- ),
- ),
- ],
- ),
- ),
- );
-
- if (selected != null) {
- final token = selected as SplToken;
- await MainDB.instance.putSplToken(token);
- unawaited(ref.read(priceAnd24hChangeNotifierProvider).updatePrice());
- if (mounted) {
- setState(() {
- tokenEntities.add(
- AddTokenListElementData(token)..selected = true,
- );
- tokenEntities.sort((a, b) => a.token.name.compareTo(b.token.name));
- });
- }
- }
- }
- }
+ // For Solana wallets, navigate to custom token addition screen.
+ await _addCustomSolanaToken();
} else {
// Original Ethereum token handling.
EthContract? contract;
@@ -344,6 +288,56 @@ class _EditWalletTokensViewState extends ConsumerState<EditWalletTokensView> {
}
}
+ /// Navigate to add custom Solana token view and handle the result.
+ Future<void> _addCustomSolanaToken() async {
+ SplToken? token;
+
+ if (isDesktop) {
+ token = await showDialog(
+ context: context,
+ builder: (context) => DesktopDialog(
+ maxWidth: 580,
+ maxHeight: 500,
+ child: AddCustomSolanaTokenView(walletId: widget.walletId),
+ ),
+ );
+ } else {
+ final result = await Navigator.of(
+ context,
+ ).pushNamed(AddCustomSolanaTokenView.routeName);
+ token = result as SplToken?;
+ }
+
+ if (token != null) {
+ await MainDB.instance.putSplToken(token);
+
+ // Also add the custom token mint address to the wallet's custom token list.
+ final wallet = ref.read(pWallets).getWallet(widget.walletId);
+ if (wallet is SolanaWallet) {
+ final currentCustomTokens = wallet.info.solanaCustomTokenMintAddresses.toSet();
+ currentCustomTokens.add(token.address);
+ await wallet.info.updateSolanaCustomTokenMintAddresses(
+ newMintAddresses: currentCustomTokens,
+ isar: MainDB.instance.isar,
+ );
+ }
+
+ unawaited(ref.read(priceAnd24hChangeNotifierProvider).updatePrice());
+ if (mounted) {
+ setState(() {
+ if (tokenEntities
+ .where((e) => e.token.address == token!.address)
+ .isEmpty) {
+ tokenEntities.add(
+ AddTokenListElementData(token!)..selected = true,
+ );
+ tokenEntities.sort((a, b) => a.token.name.compareTo(b.token.name));
+ }
+ });
+ }
+ }
+ }
+
@override
void initState() {
_searchFieldController = TextEditingController();
Why this scored 26/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.