From 716813ceafae00124529aa2a2a46d3f008a684b4 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 31 Oct 2025 17:27:44 -0500 Subject: [PATCH 01/80] feat(spl): add mock SolanaTokenWallet impl --- .../impl/sub_wallets/solana_token_wallet.dart | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart diff --git a/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart b/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart new file mode 100644 index 000000000..b0bcb60a4 --- /dev/null +++ b/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart @@ -0,0 +1,118 @@ +/* + * 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:isar_community/isar.dart'; + +import '../../../../models/paymint/fee_object_model.dart'; +import '../../../../utilities/amount/amount.dart'; +import '../../../crypto_currency/crypto_currency.dart'; +import '../../../models/tx_data.dart'; +import '../../wallet.dart'; + +/// Mock Solana Token Wallet for UI development. +/// +/// TODO: Complete implementation with real balance fetching, transaction +/// handling, and fee estimation when SolanaAPI is ready. +class SolanaTokenWallet extends Wallet { + /// Mock wallet for testing UI. + SolanaTokenWallet({ + required this.tokenMint, + required this.tokenName, + required this.tokenSymbol, + required this.tokenDecimals, + }) : super(Solana(CryptoCurrencyNetwork.main)); // TODO: make testnet-capable. + + final String tokenMint; + final String tokenName; + final String tokenSymbol; + final int tokenDecimals; + + // ========================================================================= + // Abstract method implementations + // ========================================================================= + + @override + FilterOperation? get changeAddressFilterOperation => null; + + @override + FilterOperation? get receivingAddressFilterOperation => null; + + @override + Future init() async { + await super.init(); + // TODO: Initialize token account address derivation. + await Future.delayed(const Duration(milliseconds: 100)); + } + + @override + Future prepareSend({required TxData txData}) async { + // TODO: Build SPL token transfer instruction. + throw UnimplementedError("prepareSend not yet implemented"); + } + + @override + Future confirmSend({required TxData txData}) async { + // TODO: Sign and broadcast SPL token transfer. + throw UnimplementedError("confirmSend not yet implemented"); + } + + @override + Future recover({required bool isRescan}) async { + // TODO. + } + + @override + Future updateNode() async { + // No-op for token wallet. + } + + @override + Future updateTransactions() async { + // TODO: Fetch token transfer history from Solana RPC. + } + + @override + Future updateBalance() async { + // TODO: Fetch token balance from Solana RPC. + } + + @override + Future updateUTXOs() async { + // Not applicable for Solana tokens. + return true; + } + + @override + Future updateChainHeight() async { + // TODO: Get latest Solana block height. + } + + @override + Future estimateFeeFor(Amount amount, BigInt feeRate) async { + // Mock fee estimation: 5000 lamports for token transfer. + return Amount.zeroWith(fractionDigits: tokenDecimals); + } + + @override + Future get fees async { + // TODO: Return real Solana fee estimates. + throw UnimplementedError("fees not yet implemented"); + } + + @override + Future pingCheck() async { + // TODO: Check Solana RPC connection. + return true; + } + + @override + Future checkSaveInitialReceivingAddress() async { + // Token accounts are derived, not managed separately. + } +} From 70b7f51ff7fbb5e2ddce4c01e42a88b04932863d Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 31 Oct 2025 17:38:15 -0500 Subject: [PATCH 02/80] feat(spl): add Solana token (SPL) state mgmt providers --- .../current_sol_token_wallet_provider.dart | 15 +++++++++ .../solana/sol_token_balance_provider.dart | 32 +++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 lib/wallets/isar/providers/solana/current_sol_token_wallet_provider.dart create mode 100644 lib/wallets/isar/providers/solana/sol_token_balance_provider.dart diff --git a/lib/wallets/isar/providers/solana/current_sol_token_wallet_provider.dart b/lib/wallets/isar/providers/solana/current_sol_token_wallet_provider.dart new file mode 100644 index 000000000..ab1745122 --- /dev/null +++ b/lib/wallets/isar/providers/solana/current_sol_token_wallet_provider.dart @@ -0,0 +1,15 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../wallet/impl/sub_wallets/solana_token_wallet.dart'; + +/// State provider for the currently active Solana token wallet. +/// +/// This allows global tracking of which token wallet is being viewed/interacted-with. +final solanaTokenServiceStateProvider = + StateProvider((ref) => null); + +/// Public provider to read the current active Solana token wallet. +/// +/// Use this in UI widgets to get the active token wallet. +final pCurrentSolanaTokenWallet = + Provider((ref) => ref.watch(solanaTokenServiceStateProvider)); diff --git a/lib/wallets/isar/providers/solana/sol_token_balance_provider.dart b/lib/wallets/isar/providers/solana/sol_token_balance_provider.dart new file mode 100644 index 000000000..30b06918d --- /dev/null +++ b/lib/wallets/isar/providers/solana/sol_token_balance_provider.dart @@ -0,0 +1,32 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../models/balance.dart'; +import '../../../../utilities/amount/amount.dart'; + +/// Provider family for Solana token balance. +/// +/// Currently returns mock data while API is a WIP. +/// +/// Example usage in UI: +/// final balance = ref.watch( +/// pSolanaTokenBalance((walletId: 'wallet1', tokenMint: 'EPjFWaJUwYUoRwzwkH4H8gNB7zHW9tLT6NCKB8S4yh6h')) +/// ); +final pSolanaTokenBalance = Provider.family< + Balance, + ({String walletId, String tokenMint})>((ref, params) { + // Mock data for UI development. + // TODO: when API is ready, this should fetch real balance from SolanaAPI. + return Balance( + total: Amount.fromDecimal( + Decimal.parse("1000.00"), + fractionDigits: 6, + ), + spendable: Amount.fromDecimal( + Decimal.parse("1000.00"), + fractionDigits: 6, + ), + blockedTotal: Amount.zeroWith(fractionDigits: 6), + pendingSpendable: Amount.zeroWith(fractionDigits: 6), + ); +}); From 9897a988611cc215c26e448b672258e75fbd89ad Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 31 Oct 2025 17:51:22 -0500 Subject: [PATCH 03/80] feat(spl): Solana token (SPL) UI components --- lib/pages/token_view/sol_token_view.dart | 310 +++++++++++++++++++ lib/widgets/icon_widgets/sol_token_icon.dart | 103 ++++++ 2 files changed, 413 insertions(+) create mode 100644 lib/pages/token_view/sol_token_view.dart create mode 100644 lib/widgets/icon_widgets/sol_token_icon.dart diff --git a/lib/pages/token_view/sol_token_view.dart b/lib/pages/token_view/sol_token_view.dart new file mode 100644 index 000000000..264f81f92 --- /dev/null +++ b/lib/pages/token_view/sol_token_view.dart @@ -0,0 +1,310 @@ +/* + * 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 'package:flutter_svg/svg.dart'; + +import '../../services/event_bus/events/global/wallet_sync_status_changed_event.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/assets.dart'; +import '../../utilities/constants.dart'; +import '../../utilities/text_styles.dart'; +import '../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; +import '../../wallets/isar/providers/solana/sol_token_balance_provider.dart'; +import '../../widgets/background.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/icon_widgets/sol_token_icon.dart'; + +/// Solana SPL Token View +/// +/// This view displays a Solana token with its balance, transaction history, +/// and quick action buttons (Send, Receive, More). +/// +/// Uses mock data for UI development. The backend API will be integrated later. +class SolTokenView extends ConsumerStatefulWidget { + const SolTokenView({ + super.key, + required this.walletId, + required this.tokenMint, + this.popPrevious = false, + }); + + static const String routeName = "/sol_token"; + + /// The ID of the parent Solana wallet + final String walletId; + + /// The SPL token mint address + final String tokenMint; + + /// Whether to pop the previous view when closing + final bool popPrevious; + + @override + ConsumerState createState() => _SolTokenViewState(); +} + +class _SolTokenViewState extends ConsumerState { + late final WalletSyncStatus initialSyncStatus; + + @override + void initState() { + initialSyncStatus = WalletSyncStatus.synced; + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + debugPrint("BUILD: $runtimeType"); + + // Get the current token wallet from provider + final tokenWallet = ref.watch(pCurrentSolanaTokenWallet); + + // Get the balance for this token + final balance = ref.watch( + pSolanaTokenBalance(( + walletId: widget.walletId, + tokenMint: widget.tokenMint, + )), + ); + + // If no token wallet is set, show placeholder + if (tokenWallet == null) { + return Scaffold( + backgroundColor: Theme.of(context).extension()!.background, + body: SafeArea( + child: Center( + child: Text( + "Token not loaded", + style: STextStyles.pageTitleH1(context), + ), + ), + ), + ); + } + + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; + final nav = Navigator.of(context); + if (widget.popPrevious) { + nav.pop(); + } + nav.pop(); + }, + child: Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + final nav = Navigator.of(context); + if (widget.popPrevious) { + nav.pop(); + } + nav.pop(); + }, + ), + centerTitle: true, + title: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SolTokenIcon(mintAddress: widget.tokenMint, size: 24), + const SizedBox(width: 10), + Flexible( + child: Text( + tokenWallet.tokenName, + style: STextStyles.navBarTitle(context), + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ], + ), + actions: [ + Padding( + padding: const EdgeInsets.only(right: 2), + child: AspectRatio( + aspectRatio: 1, + child: AppBarIconButton( + icon: SvgPicture.asset( + Assets.svg.verticalEllipsis, + colorFilter: ColorFilter.mode( + Theme.of( + context, + ).extension()!.topNavIconPrimary, + BlendMode.srcIn, + ), + ), + onPressed: () { + // TODO: Show context menu with more options. + }, + ), + ), + ), + ], + ), + body: SafeArea( + child: Container( + color: Theme.of(context).extension()!.background, + child: Column( + children: [ + const SizedBox(height: 10), + // Balance Display Section + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Balance", + style: STextStyles.itemSubtitle(context).copyWith( + color: Theme.of( + context, + ).extension()!.textDark3, + ), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "${balance.spendable.decimal.toStringAsFixed(tokenWallet.tokenDecimals)} ${tokenWallet.tokenSymbol}", + style: STextStyles.subtitle600(context), + ), + SolTokenIcon( + mintAddress: widget.tokenMint, + size: 32, + ), + ], + ), + ], + ), + ), + ), + ), + const SizedBox(height: 20), + // Action Buttons. + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: () { + // TODO: Navigate to send view + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Send not yet implemented"), + ), + ); + }, + icon: const Icon(Icons.send), + label: const Text("Send"), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + onPressed: () { + // TODO: Navigate to receive view. + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Receive not yet implemented"), + ), + ); + }, + icon: const Icon(Icons.call_received), + label: const Text("Receive"), + ), + ), + ], + ), + ), + const SizedBox(height: 20), + // Transaction History Section. + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Transactions", + style: STextStyles.itemSubtitle(context).copyWith( + color: Theme.of( + context, + ).extension()!.textDark3, + ), + ), + ], + ), + ), + const SizedBox(height: 12), + // Transaction List (placeholder). + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Container( + decoration: BoxDecoration( + color: Theme.of( + context, + ).extension()!.popupBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "No transactions yet", + style: STextStyles.itemSubtitle(context), + ), + const SizedBox(height: 8), + Text( + "Your token transactions will appear here", + style: STextStyles.itemSubtitle12(context), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/icon_widgets/sol_token_icon.dart b/lib/widgets/icon_widgets/sol_token_icon.dart new file mode 100644 index 000000000..e96583ee1 --- /dev/null +++ b/lib/widgets/icon_widgets/sol_token_icon.dart @@ -0,0 +1,103 @@ +/* + * 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:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:isar_community/isar.dart'; + +import '../../models/isar/exchange_cache/currency.dart'; +import '../../services/exchange/change_now/change_now_exchange.dart'; +import '../../services/exchange/exchange_data_loading_service.dart'; +import '../../themes/coin_icon_provider.dart'; +import '../../wallets/crypto_currency/crypto_currency.dart'; + +/// Token icon widget for Solana SPL tokens. +/// +/// Displays the token icon by attempting to fetch from exchange data service. +/// Falls back to generic Solana token icon if no icon is found. +class SolTokenIcon extends ConsumerStatefulWidget { + const SolTokenIcon({super.key, required this.mintAddress, this.size = 22}); + + /// The SPL token mint address. + final String mintAddress; + + /// Size of the icon in pixels. + final double size; + + @override + ConsumerState createState() => _SolTokenIconState(); +} + +class _SolTokenIconState extends ConsumerState { + String? imageUrl; + + @override + void initState() { + super.initState(); + _loadTokenIcon(); + } + + Future _loadTokenIcon() async { + try { + final isar = await ExchangeDataLoadingService.instance.isar; + final currency = await isar.currencies + .where() + .exchangeNameEqualTo(ChangeNowExchange.exchangeName) + .filter() + .tokenContractEqualTo(widget.mintAddress, caseSensitive: false) + .and() + .imageIsNotEmpty() + .findFirst(); + + if (mounted) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + imageUrl = currency?.image; + }); + } + }); + } + } catch (e) { + // Silently fail - we'll use fallback icon. + if (mounted) { + setState(() { + imageUrl = null; + }); + } + } + } + + @override + Widget build(BuildContext context) { + if (imageUrl == null || imageUrl!.isEmpty) { + // Fallback to generic Solana icon. + return SvgPicture.asset( + ref.watch(coinIconProvider(Solana(CryptoCurrencyNetwork.main))), + width: widget.size, + height: widget.size, + ); + } else { + // Display token icon from network. + return SvgPicture.network( + imageUrl!, + width: widget.size, + height: widget.size, + placeholderBuilder: (context) { + return SvgPicture.asset( + ref.watch(coinIconProvider(Solana(CryptoCurrencyNetwork.main))), + width: widget.size, + height: widget.size, + ); + }, + ); + } + } +} From 575c715223cf8f5c9735c2e69ff9734ff8f4f296 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 31 Oct 2025 18:14:08 -0500 Subject: [PATCH 04/80] feat(spl): register Solana token (SPL) view routes in nav --- lib/route_generator.dart | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/lib/route_generator.dart b/lib/route_generator.dart index b44550baf..02f52a65d 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -159,6 +159,7 @@ import 'pages/spark_names/sub_widgets/spark_name_details.dart'; import 'pages/special/firo_rescan_recovery_error_dialog.dart'; import 'pages/stack_privacy_calls.dart'; import 'pages/token_view/my_tokens_view.dart'; +import 'pages/token_view/sol_token_view.dart'; import 'pages/token_view/token_contract_details_view.dart'; import 'pages/token_view/token_view.dart'; import 'pages/wallet_view/transaction_views/all_transactions_view.dart'; @@ -2506,6 +2507,29 @@ class RouteGenerator { } return _routeError("${settings.name} invalid args: ${args.toString()}"); + case SolTokenView.routeName: + if (args is ({String walletId, String tokenMint})) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => SolTokenView( + walletId: args.walletId, + tokenMint: args.tokenMint, + ), + settings: RouteSettings(name: settings.name), + ); + } else if (args is ({String walletId, String tokenMint, bool popPrevious})) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => SolTokenView( + walletId: args.walletId, + tokenMint: args.tokenMint, + popPrevious: args.popPrevious, + ), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + // == End of desktop specific routes ===================================== default: From b9914576d87d50f2ef2527400ab85984f3683728 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 31 Oct 2025 18:42:19 -0500 Subject: [PATCH 05/80] feat(spl): flag/enable token support for Solana --- lib/wallets/crypto_currency/coins/solana.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/wallets/crypto_currency/coins/solana.dart b/lib/wallets/crypto_currency/coins/solana.dart index 03331a922..b4f40a86e 100644 --- a/lib/wallets/crypto_currency/coins/solana.dart +++ b/lib/wallets/crypto_currency/coins/solana.dart @@ -41,6 +41,9 @@ class Solana extends Bip39Currency { @override String get ticker => _ticker; + @override + bool get hasTokenSupport => true; + @override NodeModel defaultNode({required bool isPrimary}) { switch (network) { From e8327b6a87a7e8fea2d9cf39760d2568ee54b653 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sat, 1 Nov 2025 08:22:11 -0500 Subject: [PATCH 06/80] feat(spl): add Solana token (SPL) model and contract abstraction --- lib/models/isar/models/contract.dart | 12 +++- lib/models/isar/models/solana/spl_token.dart | 58 ++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 lib/models/isar/models/solana/spl_token.dart diff --git a/lib/models/isar/models/contract.dart b/lib/models/isar/models/contract.dart index 3260df084..a11383af1 100644 --- a/lib/models/isar/models/contract.dart +++ b/lib/models/isar/models/contract.dart @@ -9,5 +9,15 @@ */ abstract class Contract { - // for possible future use + /// Token/contract address (mint address for Solana, contract address for Ethereum). + String get address; + + /// Token name. + String get name; + + /// Token symbol. + String get symbol; + + /// Token decimals. + int get decimals; } diff --git a/lib/models/isar/models/solana/spl_token.dart b/lib/models/isar/models/solana/spl_token.dart new file mode 100644 index 000000000..736ee3155 --- /dev/null +++ b/lib/models/isar/models/solana/spl_token.dart @@ -0,0 +1,58 @@ +/* + * 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:isar_community/isar.dart'; +import '../contract.dart'; + +part 'spl_token.g.dart'; + +@collection +class SplToken extends Contract { + SplToken({ + required this.address, + required this.name, + required this.symbol, + required this.decimals, + this.logoUri, + this.metadataAddress, + }); + + Id id = Isar.autoIncrement; + + @Index(unique: true, replace: true) + late final String address; // Mint address. + + late final String name; + + late final String symbol; + + late final int decimals; + + late final String? logoUri; + + late final String? metadataAddress; + + SplToken copyWith({ + Id? id, + String? address, + String? name, + String? symbol, + int? decimals, + String? logoUri, + String? metadataAddress, + }) => + SplToken( + address: address ?? this.address, + name: name ?? this.name, + symbol: symbol ?? this.symbol, + decimals: decimals ?? this.decimals, + logoUri: logoUri ?? this.logoUri, + metadataAddress: metadataAddress ?? this.metadataAddress, + )..id = id ?? this.id; +} From ba5492e314bf3b7c396ab3f962837b6b70616eaf Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sat, 1 Nov 2025 11:07:49 -0500 Subject: [PATCH 07/80] feat(spl): add Solana token storage and state mgmt providers --- lib/wallets/isar/models/wallet_info.dart | 25 ++++++++++++++++ .../providers/solana/sol_tokens_provider.dart | 30 +++++++++++++++++++ .../sol_wallet_token_addresses_provider.dart | 23 ++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 lib/wallets/isar/providers/solana/sol_tokens_provider.dart create mode 100644 lib/wallets/isar/providers/solana/sol_wallet_token_addresses_provider.dart diff --git a/lib/wallets/isar/models/wallet_info.dart b/lib/wallets/isar/models/wallet_info.dart index 5b2d6569c..f4dbab507 100644 --- a/lib/wallets/isar/models/wallet_info.dart +++ b/lib/wallets/isar/models/wallet_info.dart @@ -75,6 +75,17 @@ class WalletInfo implements IsarId { } } + @ignore + List get solanaTokenMintAddresses { + if (otherData[WalletInfoKeys.solanaTokenMintAddresses] is List) { + return List.from( + otherData[WalletInfoKeys.solanaTokenMintAddresses] as List, + ); + } else { + return []; + } + } + /// Special case for coins such as firo lelantus @ignore Balance get cachedBalanceSecondary { @@ -396,6 +407,19 @@ class WalletInfo implements IsarId { ); } + /// Update Solana token mint addresses and update the db. + Future updateSolanaTokenMintAddresses({ + required Set newMintAddresses, + required Isar isar, + }) async { + await updateOtherData( + newEntries: { + WalletInfoKeys.solanaTokenMintAddresses: newMintAddresses.toList(), + }, + isar: isar, + ); + } + Future setMwebEnabled({ required bool newValue, required Isar isar, @@ -524,4 +548,5 @@ abstract class WalletInfoKeys { static const String mwebScanHeight = "mwebScanHeightKey"; static const String firoSparkUsedTagsCacheResetVersion = "firoSparkUsedTagsCacheResetVersionKey"; + static const String solanaTokenMintAddresses = "solanaTokenMintAddressesKey"; } diff --git a/lib/wallets/isar/providers/solana/sol_tokens_provider.dart b/lib/wallets/isar/providers/solana/sol_tokens_provider.dart new file mode 100644 index 000000000..1396a9110 --- /dev/null +++ b/lib/wallets/isar/providers/solana/sol_tokens_provider.dart @@ -0,0 +1,30 @@ +/* + * 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:flutter_riverpod/flutter_riverpod.dart'; + +/// Provides a list of Solana token mint addresses for a specific wallet. +/// +/// This provider returns the list of Solana SPL token mint addresses +/// that the wallet has selected. Token details are not currently persisted +/// in the database - only the mint addresses are stored in WalletInfo's otherData. +/// +/// Example usage: +/// ``` +/// final tokenAddresses = ref.watch(pSolanaWalletTokenAddresses('wallet_id')); +/// ``` +/// Note: For full token details (name, symbol, decimals), these would need to be +/// fetched from the Solana token metadata or a token list API. +final pSolanaWalletTokens = Provider.family, String>( + (ref, walletId) { + // TODO: Implement token details fetching from Solana metadata or API. + // For now, just return an empty list as token details are not persisted. + return []; + }, +); diff --git a/lib/wallets/isar/providers/solana/sol_wallet_token_addresses_provider.dart b/lib/wallets/isar/providers/solana/sol_wallet_token_addresses_provider.dart new file mode 100644 index 000000000..defccf4c3 --- /dev/null +++ b/lib/wallets/isar/providers/solana/sol_wallet_token_addresses_provider.dart @@ -0,0 +1,23 @@ +/* + * 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:flutter_riverpod/flutter_riverpod.dart'; + +import '../wallet_info_provider.dart'; + +/// Provides the list of Solana SPL token mint addresses for a wallet. +/// +/// This is a family provider that takes a walletId and returns the list of +/// mint addresses from the WalletInfo's otherData. +final pSolanaWalletTokenAddresses = Provider.family, String>( + (ref, walletId) { + final walletInfo = ref.watch(pWalletInfo(walletId)); + return walletInfo.solanaTokenMintAddresses; + }, +); From 4fe52e029fc3c1c1f4a7fc206525ef7bf5f388ed Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sat, 1 Nov 2025 14:19:36 -0500 Subject: [PATCH 08/80] feat(spl): implement Solana token selection --- .../edit_wallet_tokens_view.dart | 125 ++++++- .../sub_widgets/add_token_list_element.dart | 11 +- lib/services/solana/solana_token_api.dart | 318 ++++++++++++++++++ lib/utilities/default_spl_tokens.dart | 50 +++ 4 files changed, 481 insertions(+), 23 deletions(-) create mode 100644 lib/services/solana/solana_token_api.dart create mode 100644 lib/utilities/default_spl_tokens.dart 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 b1f07cec7..fb37dd244 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 { .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 { 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 { _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 a477c5769..eecf914d7 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 { 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 000000000..63e0161bc --- /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 { + 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 json) { + Map? parsed; + Map? infoMap; + + try { + final data = json['data']; + if (data is Map) { + final dataMap = Map.from(data); + final parsedVal = dataMap['parsed']; + if (parsedVal is Map) { + parsed = Map.from(parsedVal); + } + } + if (parsed != null) { + final infoVal = parsed['info']; + if (infoVal is Map) { + infoMap = Map.from(infoVal); + } + } + } catch (e) { + // Silently ignore parsing errors, use empty map + } + + final info = infoMap ?? {}; + + final owner = info['owner']; + final mint = info['mint']; + final tokenAmount = info['tokenAmount']; + final amountStr = (tokenAmount is Map) ? (tokenAmount as Map)['amount'] : null; + final decimalsVal = (tokenAmount is Map) ? (tokenAmount as Map)['decimals'] : null; + + final isNative = (parsed is Map) + ? ((parsed as Map)['type'] == 'account' && + (parsed as Map)['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>> 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>( + value: ['TokenAccount_${ownerAddress}_$mint'], + ); + } + + return SolanaTokenApiResponse>(value: []); + } on Exception catch (e) { + return SolanaTokenApiResponse>( + 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> 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( + value: BigInt.from(1000000), + ); + } on Exception catch (e) { + return SolanaTokenApiResponse( + 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> 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( + value: BigInt.parse('1000000000000000000'), + ); + } on Exception catch (e) { + return SolanaTokenApiResponse( + 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> + getTokenAccountInfo(String tokenAccountAddress) async { + try { + _checkClient(); + + // Return placeholder data. + // TODO: Implement actual RPC call using proper client methods. + return SolanaTokenApiResponse( + value: TokenAccountInfo( + address: tokenAccountAddress, + owner: 'placeholder_owner', + mint: 'EPjFWaJUwYUoRwzwkH4H8gNB7zHW9tLT6NCKB8S4yh6h', + balance: BigInt.from(1000000000), + decimals: 6, + isNative: false, + ), + ); + } on Exception catch (e) { + return SolanaTokenApiResponse( + 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> ownsToken( + String ownerAddress, + String mint, + ) async { + try { + _checkClient(); + + // Return placeholder. + // TODO: Implement actual RPC call to getTokenAccountsByOwner with mint filter. + return SolanaTokenApiResponse(value: false); + } on Exception catch (e) { + return SolanaTokenApiResponse( + 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 000000000..5d65251f6 --- /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 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", + ), + ]; +} From 4ec577cd33cfc858bf734453d02ac0da6e1913f1 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sat, 1 Nov 2025 16:45:58 -0500 Subject: [PATCH 09/80] fix(spl): correct USDC mint address for Solana --- lib/services/solana/solana_token_api.dart | 2 +- lib/utilities/default_spl_tokens.dart | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/services/solana/solana_token_api.dart b/lib/services/solana/solana_token_api.dart index 63e0161bc..6282bddd3 100644 --- a/lib/services/solana/solana_token_api.dart +++ b/lib/services/solana/solana_token_api.dart @@ -255,7 +255,7 @@ class SolanaTokenAPI { value: TokenAccountInfo( address: tokenAccountAddress, owner: 'placeholder_owner', - mint: 'EPjFWaJUwYUoRwzwkH4H8gNB7zHW9tLT6NCKB8S4yh6h', + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', balance: BigInt.from(1000000000), decimals: 6, isNative: false, diff --git a/lib/utilities/default_spl_tokens.dart b/lib/utilities/default_spl_tokens.dart index 5d65251f6..603ee9ccf 100644 --- a/lib/utilities/default_spl_tokens.dart +++ b/lib/utilities/default_spl_tokens.dart @@ -12,11 +12,11 @@ import '../models/isar/models/solana/spl_token.dart'; abstract class DefaultSplTokens { static List list = [ SplToken( - address: "EPjFWaJUwYUoRwzwkH4H8gNB7zHW9tLT6NCKB8S4yh6h", + address: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", name: "USD Coin", symbol: "USDC", decimals: 6, - logoUri: "https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/EPjFWaJUwYUoRwzwkH4H8gNB7zHW9tLT6NCKB8S4yh6h/logo.png", + logoUri: "https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v/logo.png", ), SplToken( address: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenEst", From 651f1fcf86c297c8418f56463768ee3853228497 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sun, 2 Nov 2025 09:31:42 -0600 Subject: [PATCH 10/80] feat(spl): working token ownership check --- lib/services/solana/solana_token_api.dart | 57 ++++++++++++----------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/lib/services/solana/solana_token_api.dart b/lib/services/solana/solana_token_api.dart index 6282bddd3..1df8736e5 100644 --- a/lib/services/solana/solana_token_api.dart +++ b/lib/services/solana/solana_token_api.dart @@ -7,6 +7,7 @@ * */ +import 'package:solana/dto.dart'; import 'package:solana/solana.dart'; /// Exception for Solana token API errors. @@ -143,14 +144,7 @@ class SolanaTokenAPI { /// 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. + /// Returns a list of token account addresses owned by the wallet. Future>> getTokenAccountsByOwner( String ownerAddress, { String? mint, @@ -158,17 +152,25 @@ class SolanaTokenAPI { 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>( - value: ['TokenAccount_${ownerAddress}_$mint'], - ); - } + const splTokenProgramId = 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA'; - return SolanaTokenApiResponse>(value: []); + final result = await _rpcClient!.getTokenAccountsByOwner( + ownerAddress, + // Create the appropriate filter: by mint if specified, or else all SPL tokens. + mint != null + ? TokenAccountsFilter.byMint(mint) + : TokenAccountsFilter.byProgramId(splTokenProgramId), + encoding: Encoding.jsonParsed, + ); + + // Extract token account addresses from the RPC response. + final accountAddresses = result.value + .map((account) => account.pubkey) + .toList(); + + return SolanaTokenApiResponse>( + value: accountAddresses, + ); } on Exception catch (e) { return SolanaTokenApiResponse>( exception: SolanaTokenApiException( @@ -289,13 +291,7 @@ class SolanaTokenAPI { /// 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> ownsToken( String ownerAddress, String mint, @@ -303,9 +299,16 @@ class SolanaTokenAPI { try { _checkClient(); - // Return placeholder. - // TODO: Implement actual RPC call to getTokenAccountsByOwner with mint filter. - return SolanaTokenApiResponse(value: false); + // Get token accounts for this owner and mint. + final accounts = await getTokenAccountsByOwner(ownerAddress, mint: mint); + + if (accounts.isError) { + return SolanaTokenApiResponse(exception: accounts.exception); + } + + // If we got token accounts, the user owns this token. + final hasTokenAccount = accounts.value != null && (accounts.value as List).isNotEmpty; + return SolanaTokenApiResponse(value: hasTokenAccount); } on Exception catch (e) { return SolanaTokenApiResponse( exception: SolanaTokenApiException( From de1b0ce770aeff2e462312e86fcd4e65acf27bf8 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sun, 2 Nov 2025 12:14:27 -0600 Subject: [PATCH 11/80] feat(spl): implement balance extraction in SolanaTokenAPI --- lib/services/solana/solana_token_api.dart | 108 ++++++++++++++++++++-- 1 file changed, 102 insertions(+), 6 deletions(-) diff --git a/lib/services/solana/solana_token_api.dart b/lib/services/solana/solana_token_api.dart index 1df8736e5..4feb4b01f 100644 --- a/lib/services/solana/solana_token_api.dart +++ b/lib/services/solana/solana_token_api.dart @@ -187,19 +187,115 @@ class SolanaTokenAPI { /// - 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> 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( - value: BigInt.from(1000000), + // Query the token account with jsonParsed encoding to get token amount. + final response = await _rpcClient!.getAccountInfo( + tokenAccountAddress, + encoding: Encoding.jsonParsed, ); + + if (response.value == null) { + // Token account doesn't exist. + return SolanaTokenApiResponse( + value: BigInt.zero, + ); + } + + final accountData = response.value!; + + // Extract token amount from parsed data. + try { + // Debug: Print the structure of accountData. + print('[SOLANA_TOKEN_API] accountData type: ${accountData.runtimeType}'); + print('[SOLANA_TOKEN_API] accountData.data type: ${accountData.data.runtimeType}'); + print('[SOLANA_TOKEN_API] accountData.data: ${accountData.data}'); + + // The solana package returns a ParsedAccountData which is a sealed class/union type. + // For SPL Token accounts, it contains SplTokenProgramAccountData. + + final parsedData = accountData.data; + + if (parsedData is ParsedAccountData) { + print('[SOLANA_TOKEN_API] ParsedAccountData detected'); + + try { + final extractedBalance = parsedData.when( + splToken: (spl) { + print('[SOLANA_TOKEN_API] Handling splToken variant'); + print('[SOLANA_TOKEN_API] spl type: ${spl.runtimeType}'); + + return spl.when( + account: (info, type, accountType) { + print('[SOLANA_TOKEN_API] Handling account variant'); + print('[SOLANA_TOKEN_API] info type: ${info.runtimeType}'); + print('[SOLANA_TOKEN_API] info.tokenAmount: ${info.tokenAmount}'); + + try { + final tokenAmount = info.tokenAmount; + print('[SOLANA_TOKEN_API] tokenAmount.amount: ${tokenAmount.amount}'); + print('[SOLANA_TOKEN_API] tokenAmount.decimals: ${tokenAmount.decimals}'); + + final balanceBigInt = BigInt.parse(tokenAmount.amount); + print('[SOLANA_TOKEN_API] Successfully extracted balance: $balanceBigInt'); + return balanceBigInt; + } catch (e) { + print('[SOLANA_TOKEN_API] Error extracting balance: $e'); + return null; + } + }, + mint: (info, type, accountType) { + print('[SOLANA_TOKEN_API] Got mint variant (not expected for token account balance)'); + return null; + }, + unknown: (type) { + print('[SOLANA_TOKEN_API] Got unknown account variant'); + return null; + }, + ); + }, + stake: (_) { + print('[SOLANA_TOKEN_API] Got stake account type (not expected)'); + return null; + }, + token2022: (_) { + print('[SOLANA_TOKEN_API] Got token2022 account type (not expected)'); + return null; + }, + unsupported: (_) { + print('[SOLANA_TOKEN_API] Got unsupported account type'); + return null; + }, + ); + + if (extractedBalance != null && extractedBalance is BigInt) { + print('[SOLANA_TOKEN_API] Extracted balance: $extractedBalance'); + return SolanaTokenApiResponse( + value: extractedBalance as BigInt, + ); + } + } catch (e) { + print('[SOLANA_TOKEN_API] Error using when() method: $e'); + print('[SOLANA_TOKEN_API] Stack trace: ${StackTrace.current}'); + } + } + + // If we can't extract from the Dart object, return zero. + print('[SOLANA_TOKEN_API] Returning zero balance'); + return SolanaTokenApiResponse( + value: BigInt.zero, + ); + } catch (e) { + // If parsing fails, return zero balance. + print('[SOLANA_TOKEN_API] Exception during parsing: $e'); + return SolanaTokenApiResponse( + value: BigInt.zero, + ); + } } on Exception catch (e) { return SolanaTokenApiResponse( exception: SolanaTokenApiException( From 230ed7e97ed350e671d603d1b303cddc9f6c79aa Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sun, 2 Nov 2025 15:38:15 -0600 Subject: [PATCH 12/80] feat(spl): add SplToken to AmountFormatter for ticker display --- lib/utilities/amount/amount_formatter.dart | 3 +++ lib/utilities/amount/amount_unit.dart | 29 ++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/lib/utilities/amount/amount_formatter.dart b/lib/utilities/amount/amount_formatter.dart index 44746b8cd..ead3c6267 100644 --- a/lib/utilities/amount/amount_formatter.dart +++ b/lib/utilities/amount/amount_formatter.dart @@ -1,5 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../models/isar/models/ethereum/eth_contract.dart'; +import '../../models/isar/models/solana/spl_token.dart'; import '../../providers/global/locale_provider.dart'; import '../../providers/global/prefs_provider.dart'; import 'amount.dart'; @@ -52,6 +53,7 @@ class AmountFormatter { Amount amount, { String? overrideUnit, EthContract? ethContract, + SplToken? splToken, bool withUnitName = true, bool indicatePrecisionLoss = true, }) { @@ -64,6 +66,7 @@ class AmountFormatter { indicatePrecisionLoss: indicatePrecisionLoss, overrideUnit: overrideUnit, tokenContract: ethContract, + splToken: splToken, ); } diff --git a/lib/utilities/amount/amount_unit.dart b/lib/utilities/amount/amount_unit.dart index 79e45232b..2ddfe8360 100644 --- a/lib/utilities/amount/amount_unit.dart +++ b/lib/utilities/amount/amount_unit.dart @@ -12,6 +12,7 @@ import 'dart:math' as math; import 'package:decimal/decimal.dart'; import '../../models/isar/models/ethereum/eth_contract.dart'; +import '../../models/isar/models/solana/spl_token.dart'; import 'amount.dart'; import '../util.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; @@ -175,6 +176,27 @@ extension AmountUnitExt on AmountUnit { } } + String unitForSplToken(SplToken token) { + switch (this) { + case AmountUnit.normal: + return token.symbol; + case AmountUnit.milli: + return "m${token.symbol}"; + case AmountUnit.micro: + return "µ${token.symbol}"; + case AmountUnit.nano: + case AmountUnit.pico: + case AmountUnit.femto: + case AmountUnit.atto: + case AmountUnit.zepto: + case AmountUnit.yocto: + case AmountUnit.ronto: + case AmountUnit.quecto: + // For SPL tokens, just use the symbol with the prefix if applicable. + return token.symbol; + } + } + Amount? tryParse( String value, { required String locale, @@ -231,6 +253,7 @@ extension AmountUnitExt on AmountUnit { bool indicatePrecisionLoss = true, String? overrideUnit, EthContract? tokenContract, + SplToken? splToken, }) { assert(maxDecimalPlaces >= 0); @@ -274,6 +297,10 @@ extension AmountUnitExt on AmountUnit { updatedMax = maxDecimalPlaces > tokenContract.decimals ? tokenContract.decimals : maxDecimalPlaces; + } else if (splToken != null) { + updatedMax = maxDecimalPlaces > splToken.decimals + ? splToken.decimals + : maxDecimalPlaces; } else { updatedMax = maxDecimalPlaces > coin.fractionDigits ? coin.fractionDigits @@ -329,6 +356,8 @@ extension AmountUnitExt on AmountUnit { // return the value with the proper unit symbol if (tokenContract != null) { overrideUnit = unitForContract(tokenContract); + } else if (splToken != null) { + overrideUnit = unitForSplToken(splToken); } return "$returnValue ${overrideUnit ?? unitForCoin(coin)}"; From 03617f658037c7edeeb7291dec0e4306cdb958f7 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sun, 2 Nov 2025 18:23:44 -0600 Subject: [PATCH 13/80] feat(spl): db methods and schema for Solana token (SPL) tokens --- lib/db/isar/main_db.dart | 23 +++++++++++++++++++++++ lib/models/isar/models/isar_models.dart | 1 + 2 files changed, 24 insertions(+) diff --git a/lib/db/isar/main_db.dart b/lib/db/isar/main_db.dart index 94f27e1f8..61b75c9cc 100644 --- a/lib/db/isar/main_db.dart +++ b/lib/db/isar/main_db.dart @@ -59,6 +59,7 @@ class MainDB { AddressSchema, AddressLabelSchema, EthContractSchema, + SplTokenSchema, TransactionBlockExplorerSchema, StackThemeSchema, ContactEntrySchema, @@ -621,4 +622,26 @@ class MainDB { isar.writeTxn(() async { await isar.ethContracts.putAll(contracts); }); + + // ========== Solana ========================================================= + + // Solana (SPL) tokens. + + QueryBuilder getSplTokens() => + isar.splTokens.where(); + + Future getSplToken(String tokenMint) => + isar.splTokens.where().addressEqualTo(tokenMint).findFirst(); + + SplToken? getSplTokenSync(String tokenMint) => + isar.splTokens.where().addressEqualTo(tokenMint).findFirstSync(); + + Future putSplToken(SplToken token) => isar.writeTxn(() async { + return await isar.splTokens.put(token); + }); + + Future putSplTokens(List tokens) => + isar.writeTxn(() async { + await isar.splTokens.putAll(tokens); + }); } diff --git a/lib/models/isar/models/isar_models.dart b/lib/models/isar/models/isar_models.dart index ce7652a46..d164ec62b 100644 --- a/lib/models/isar/models/isar_models.dart +++ b/lib/models/isar/models/isar_models.dart @@ -16,4 +16,5 @@ export 'blockchain_data/transaction.dart'; export 'blockchain_data/utxo.dart'; export 'ethereum/eth_contract.dart'; export 'log.dart'; +export 'solana/spl_token.dart'; export 'transaction_note.dart'; From 8d42100662bd4d8f75f0bfbcfdfeedef65a619e7 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 3 Nov 2025 08:41:33 -0600 Subject: [PATCH 14/80] feat(spl): add token selection and list widgets for Solana tokens (SPL) --- .../sub_widgets/sol_token_select_item.dart | 163 ++++++++++++++++++ .../sub_widgets/sol_tokens_list.dart | 91 ++++++++++ 2 files changed, 254 insertions(+) create mode 100644 lib/pages/token_view/sub_widgets/sol_token_select_item.dart create mode 100644 lib/pages/token_view/sub_widgets/sol_tokens_list.dart diff --git a/lib/pages/token_view/sub_widgets/sol_token_select_item.dart b/lib/pages/token_view/sub_widgets/sol_token_select_item.dart new file mode 100644 index 000000000..37c818c1d --- /dev/null +++ b/lib/pages/token_view/sub_widgets/sol_token_select_item.dart @@ -0,0 +1,163 @@ +/* + * 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:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../models/isar/models/solana/spl_token.dart'; +import '../../../pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart'; +import '../../../providers/providers.dart'; +import '../../../themes/stack_colors.dart'; +import '../../../utilities/constants.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../utilities/util.dart'; +import '../../../widgets/icon_widgets/sol_token_icon.dart'; +import '../../../widgets/rounded_white_container.dart'; +import '../sol_token_view.dart'; + +class SolTokenSelectItem extends ConsumerStatefulWidget { + const SolTokenSelectItem({ + super.key, + required this.walletId, + required this.token, + }); + + final String walletId; + final SplToken token; + + @override + ConsumerState createState() => _SolTokenSelectItemState(); +} + +class _SolTokenSelectItemState extends ConsumerState { + final bool isDesktop = Util.isDesktop; + + void _onPressed() async { + // TODO [prio=high]: Implement Solana token wallet setup and navigation. + if (mounted) { + await Navigator.of(context).pushNamed( + isDesktop ? DesktopSolTokenView.routeName : SolTokenView.routeName, + arguments: ( + walletId: widget.walletId, + tokenMint: widget.token.address, + ), + ); + } + } + + @override + Widget build(BuildContext context) { + String? priceString; + if (ref.watch(prefsChangeNotifierProvider.select((s) => s.externalCalls))) { + priceString = ref.watch( + priceAnd24hChangeNotifierProvider.select( + (s) => + s.getTokenPrice(widget.token.address)?.value.toStringAsFixed(2), + ), + ); + } + + return RoundedWhiteContainer( + padding: const EdgeInsets.all(0), + child: MaterialButton( + key: Key("walletListItemButtonKey_${widget.token.symbol}"), + padding: + isDesktop + ? const EdgeInsets.symmetric(horizontal: 28, vertical: 24) + : const EdgeInsets.symmetric(horizontal: 12, vertical: 13), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + onPressed: _onPressed, + child: Row( + children: [ + const SolTokenIcon( + mintAddress: "TODO_TOKEN_MINT", // TODO [prio=high]: Replace with widget.token.address. + size: 32, + ), + SizedBox(width: isDesktop ? 12 : 10), + Expanded( + child: Consumer( + builder: (_, ref, __) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Text( + widget.token.name, + style: + isDesktop + ? STextStyles.desktopTextExtraSmall( + context, + ).copyWith( + color: + Theme.of( + context, + ).extension()!.textDark, + ) + : STextStyles.titleBold12(context), + ), + const Spacer(), + Text( + "0.00", // TODO [prio=high]: Replace with actual Solana token balance. + style: + isDesktop + ? STextStyles.desktopTextExtraSmall( + context, + ).copyWith( + color: + Theme.of( + context, + ).extension()!.textDark, + ) + : STextStyles.itemSubtitle(context), + ), + ], + ), + const SizedBox(height: 2), + Row( + children: [ + Text( + widget.token.symbol, + style: + isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ) + : STextStyles.itemSubtitle(context), + ), + const Spacer(), + if (priceString != null) + Text( + "$priceString " + "${ref.watch(prefsChangeNotifierProvider.select((value) => value.currency))}", + style: + isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ) + : STextStyles.itemSubtitle(context), + ), + ], + ), + ], + ); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/token_view/sub_widgets/sol_tokens_list.dart b/lib/pages/token_view/sub_widgets/sol_tokens_list.dart new file mode 100644 index 000000000..070b0e8f1 --- /dev/null +++ b/lib/pages/token_view/sub_widgets/sol_tokens_list.dart @@ -0,0 +1,91 @@ +/* + * 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:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../models/isar/models/solana/spl_token.dart'; +import '../../../utilities/default_spl_tokens.dart'; +import '../../../utilities/util.dart'; +import 'sol_token_select_item.dart'; + +class SolanaTokensList extends StatelessWidget { + const SolanaTokensList({ + super.key, + required this.walletId, + required this.searchTerm, + required this.tokenMints, + }); + + final String walletId; + final String searchTerm; + final List tokenMints; + + List _filter(String searchTerm, List allTokens) { + if (tokenMints.isEmpty) { + return []; + } + + // Filter to only tokens in the wallet's token list. + var filtered = allTokens + .where((token) => tokenMints.contains(token.address)) + .toList(); + + // Apply search filter if provided. + if (searchTerm.isNotEmpty) { + final term = searchTerm.toLowerCase(); + filtered = filtered + .where((token) => + token.name.toLowerCase().contains(term) || + token.symbol.toLowerCase().contains(term) || + token.address.toLowerCase().contains(term)) + .toList(); + } + + return filtered; + } + + @override + Widget build(BuildContext context) { + final bool isDesktop = Util.isDesktop; + + return Consumer( + builder: (_, ref, __) { + // Get all available SPL tokens from the default list. + // TODO [prio=high]: This should be fetched from the database and/or API. + final allTokens = DefaultSplTokens.list; + final tokens = _filter(searchTerm, allTokens); + + if (tokens.isEmpty) { + return Center( + child: Text( + "No tokens in this wallet", + style: Theme.of(context).textTheme.bodyMedium, + ), + ); + } + + return ListView.builder( + itemCount: tokens.length, + itemBuilder: (ctx, index) { + final token = tokens[index]; + return Padding( + key: Key(token.address), + padding: + isDesktop + ? const EdgeInsets.symmetric(vertical: 5) + : const EdgeInsets.all(4), + child: SolTokenSelectItem(walletId: walletId, token: token), + ); + }, + ); + }, + ); + } +} From 08346f1103351469f7b5ad847185acd12acfcec1 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 3 Nov 2025 11:22:56 -0600 Subject: [PATCH 15/80] feat(spl): add mobile Solana token detail view --- lib/pages/token_view/sol_token_view.dart | 224 ++++++++--------------- 1 file changed, 73 insertions(+), 151 deletions(-) diff --git a/lib/pages/token_view/sol_token_view.dart b/lib/pages/token_view/sol_token_view.dart index 264f81f92..7ff9570d7 100644 --- a/lib/pages/token_view/sol_token_view.dart +++ b/lib/pages/token_view/sol_token_view.dart @@ -7,6 +7,7 @@ * */ +import 'package:event_bus/event_bus.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; @@ -16,36 +17,29 @@ import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; import '../../utilities/constants.dart'; import '../../utilities/text_styles.dart'; -import '../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; -import '../../wallets/isar/providers/solana/sol_token_balance_provider.dart'; import '../../widgets/background.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/custom_buttons/blue_text_button.dart'; import '../../widgets/icon_widgets/sol_token_icon.dart'; +import 'sub_widgets/token_summary.dart'; +import 'sub_widgets/token_transaction_list_widget.dart'; -/// Solana SPL Token View -/// -/// This view displays a Solana token with its balance, transaction history, -/// and quick action buttons (Send, Receive, More). -/// -/// Uses mock data for UI development. The backend API will be integrated later. +/// [eventBus] should only be set during testing. class SolTokenView extends ConsumerStatefulWidget { const SolTokenView({ super.key, required this.walletId, required this.tokenMint, this.popPrevious = false, + this.eventBus, }); static const String routeName = "/sol_token"; - /// The ID of the parent Solana wallet final String walletId; - - /// The SPL token mint address final String tokenMint; - - /// Whether to pop the previous view when closing final bool popPrevious; + final EventBus? eventBus; @override ConsumerState createState() => _SolTokenViewState(); @@ -56,6 +50,7 @@ class _SolTokenViewState extends ConsumerState { @override void initState() { + // TODO: Integrate Solana token refresh status when available. initialSyncStatus = WalletSyncStatus.synced; super.initState(); } @@ -69,47 +64,19 @@ class _SolTokenViewState extends ConsumerState { Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType"); - // Get the current token wallet from provider - final tokenWallet = ref.watch(pCurrentSolanaTokenWallet); - - // Get the balance for this token - final balance = ref.watch( - pSolanaTokenBalance(( - walletId: widget.walletId, - tokenMint: widget.tokenMint, - )), - ); - - // If no token wallet is set, show placeholder - if (tokenWallet == null) { - return Scaffold( - backgroundColor: Theme.of(context).extension()!.background, - body: SafeArea( - child: Center( - child: Text( - "Token not loaded", - style: STextStyles.pageTitleH1(context), - ), - ), - ), - ); - } - - return PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, _) { - if (didPop) return; + return WillPopScope( + onWillPop: () async { final nav = Navigator.of(context); if (widget.popPrevious) { nav.pop(); } nav.pop(); + return false; }, child: Background( child: Scaffold( - backgroundColor: Theme.of( - context, - ).extension()!.background, + backgroundColor: + Theme.of(context).extension()!.background, appBar: AppBar( leading: AppBarBackButton( onPressed: () { @@ -129,11 +96,14 @@ class _SolTokenViewState extends ConsumerState { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - SolTokenIcon(mintAddress: widget.tokenMint, size: 24), + SolTokenIcon( + mintAddress: widget.tokenMint, + size: 24, + ), const SizedBox(width: 10), Flexible( child: Text( - tokenWallet.tokenName, + "Token Name", // TODO: Replace with actual token name from SplToken. style: STextStyles.navBarTitle(context), overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, @@ -152,15 +122,20 @@ class _SolTokenViewState extends ConsumerState { child: AppBarIconButton( icon: SvgPicture.asset( Assets.svg.verticalEllipsis, - colorFilter: ColorFilter.mode( - Theme.of( - context, - ).extension()!.topNavIconPrimary, - BlendMode.srcIn, - ), + color: + Theme.of( + context, + ).extension()!.topNavIconPrimary, ), onPressed: () { - // TODO: Show context menu with more options. + // TODO: Implement token details navigation for Solana. + // Navigator.of(context).pushNamed( + // TokenContractDetailsView.routeName, + // arguments: Tuple2( + // widget.tokenMint, + // widget.walletId, + // ), + // ); }, ), ), @@ -173,85 +148,14 @@ class _SolTokenViewState extends ConsumerState { child: Column( children: [ const SizedBox(height: 10), - // Balance Display Section Padding( padding: const EdgeInsets.symmetric(horizontal: 16), - child: Card( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Balance", - style: STextStyles.itemSubtitle(context).copyWith( - color: Theme.of( - context, - ).extension()!.textDark3, - ), - ), - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "${balance.spendable.decimal.toStringAsFixed(tokenWallet.tokenDecimals)} ${tokenWallet.tokenSymbol}", - style: STextStyles.subtitle600(context), - ), - SolTokenIcon( - mintAddress: widget.tokenMint, - size: 32, - ), - ], - ), - ], - ), - ), + child: TokenSummary( + walletId: widget.walletId, + initialSyncStatus: initialSyncStatus, ), ), const SizedBox(height: 20), - // Action Buttons. - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - Expanded( - child: ElevatedButton.icon( - onPressed: () { - // TODO: Navigate to send view - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text("Send not yet implemented"), - ), - ); - }, - icon: const Icon(Icons.send), - label: const Text("Send"), - ), - ), - const SizedBox(width: 12), - Expanded( - child: ElevatedButton.icon( - onPressed: () { - // TODO: Navigate to receive view. - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text("Receive not yet implemented"), - ), - ); - }, - icon: const Icon(Icons.call_received), - label: const Text("Receive"), - ), - ), - ], - ), - ), - const SizedBox(height: 20), - // Transaction History Section. Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( @@ -260,41 +164,59 @@ class _SolTokenViewState extends ConsumerState { Text( "Transactions", style: STextStyles.itemSubtitle(context).copyWith( - color: Theme.of( - context, - ).extension()!.textDark3, + color: + Theme.of( + context, + ).extension()!.textDark3, ), ), + CustomTextButton( + text: "See all", + onTap: () { + // TODO: Navigate to all transactions for this token. + // Navigator.of(context).pushNamed( + // AllTransactionsV2View.routeName, + // arguments: ( + // walletId: widget.walletId, + // tokenMint: widget.tokenMint, + // ), + // ); + }, + ), ], ), ), const SizedBox(height: 12), - // Transaction List (placeholder). Expanded( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), - child: Container( - decoration: BoxDecoration( - color: Theme.of( - context, - ).extension()!.popupBG, - borderRadius: BorderRadius.circular( + child: ClipRRect( + borderRadius: BorderRadius.vertical( + top: Radius.circular( + Constants.size.circularBorderRadius, + ), + bottom: Radius.circular( + // TokenView.navBarHeight / 2.0, Constants.size.circularBorderRadius, ), ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - "No transactions yet", - style: STextStyles.itemSubtitle(context), + child: Container( + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, ), - const SizedBox(height: 8), - Text( - "Your token transactions will appear here", - style: STextStyles.itemSubtitle12(context), - ), - ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: TokenTransactionsList( + walletId: widget.walletId, + ), + ), + ], + ), ), ), ), From a25e51aebd6c322b407e0f168c4c8c91b5b382a3 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 3 Nov 2025 14:09:18 -0600 Subject: [PATCH 16/80] feat(spl): add desktop Solana token (SPL) detail view --- .../wallet_view/desktop_sol_token_view.dart | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart new file mode 100644 index 000000000..45204e3f6 --- /dev/null +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart @@ -0,0 +1,226 @@ +/* + * 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:event_bus/event_bus.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../../../pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart'; +import '../../../providers/providers.dart'; +import '../../../services/event_bus/events/global/wallet_sync_status_changed_event.dart'; +import '../../../themes/stack_colors.dart'; +import '../../../utilities/assets.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../../widgets/coin_ticker_tag.dart'; +import '../../../widgets/custom_buttons/blue_text_button.dart'; +import '../../../widgets/desktop/desktop_app_bar.dart'; +import '../../../widgets/desktop/desktop_scaffold.dart'; +import '../../../widgets/desktop/secondary_button.dart'; +import '../../../widgets/icon_widgets/sol_token_icon.dart'; +import '../../../widgets/rounded_white_container.dart'; +import 'sub_widgets/desktop_wallet_features.dart'; +import 'sub_widgets/desktop_wallet_summary.dart'; +import 'sub_widgets/my_wallet.dart'; + +/// [eventBus] should only be set during testing. +class DesktopSolTokenView extends ConsumerStatefulWidget { + const DesktopSolTokenView({ + super.key, + required this.walletId, + required this.tokenMint, + this.eventBus, + }); + + static const String routeName = "/desktopSolTokenView"; + + final String walletId; + final String tokenMint; + final EventBus? eventBus; + + @override + ConsumerState createState() => _DesktopTokenViewState(); +} + +class _DesktopTokenViewState extends ConsumerState { + static const double sendReceiveColumnWidth = 460; + + late final WalletSyncStatus initialSyncStatus; + + @override + void initState() { + // TODO: Integrate Solana token refresh status when available. + initialSyncStatus = WalletSyncStatus.synced; + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + debugPrint("BUILD: $runtimeType"); + + return DesktopScaffold( + appBar: DesktopAppBar( + background: Theme.of(context).extension()!.popupBG, + leading: Expanded( + flex: 3, + child: Row( + children: [ + const SizedBox(width: 32), + SecondaryButton( + padding: const EdgeInsets.only(left: 12, right: 18), + buttonHeight: ButtonHeight.s, + label: ref.watch(pWalletName(widget.walletId)), + icon: SvgPicture.asset( + Assets.svg.arrowLeft, + width: 18, + height: 18, + color: Theme.of( + context, + ).extension()!.topNavIconPrimary, + ), + onPressed: () { + ref.refresh(feeSheetSessionCacheProvider); + Navigator.of(context).pop(); + }, + ), + const SizedBox(width: 15), + ], + ), + ), + center: Expanded( + flex: 4, + child: Row( + children: [ + SolTokenIcon(mintAddress: widget.tokenMint, size: 32), + const SizedBox(width: 12), + Text( + "Token Name", // TODO: Replace with actual token name from SplToken. + style: STextStyles.desktopH3(context), + ), + const SizedBox(width: 12), + CoinTickerTag( + ticker: ref.watch( + pWalletCoin(widget.walletId).select((s) => s.ticker), + ), + ), + ], + ), + ), + useSpacers: false, + isCompactHeight: true, + ), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + RoundedWhiteContainer( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + SolTokenIcon(mintAddress: widget.tokenMint, size: 40), + const SizedBox(width: 10), + DesktopWalletSummary( + walletId: widget.walletId, + isToken: true, + initialSyncStatus: + ref + .watch(pWallets) + .getWallet(widget.walletId) + .refreshMutex + .isLocked + ? WalletSyncStatus.syncing + : WalletSyncStatus.synced, + ), + const Spacer(), + DesktopWalletFeatures(walletId: widget.walletId), + ], + ), + ), + const SizedBox(height: 24), + Row( + children: [ + SizedBox( + width: sendReceiveColumnWidth, + child: Text( + "My wallet", + style: STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldActiveSearchIconLeft, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Recent transactions", + style: STextStyles.desktopTextExtraSmall(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textFieldActiveSearchIconLeft, + ), + ), + CustomTextButton( + text: "See all", + onTap: () { + // TODO: Navigate to all transactions for this token + // Navigator.of(context).pushNamed( + // AllTransactionsV2View.routeName, + // arguments: ( + // walletId: widget.walletId, + // tokenMint: "TODO_TOKEN_MINT", + // ), + // ); + }, + ), + ], + ), + ), + ], + ), + const SizedBox(height: 14), + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: sendReceiveColumnWidth, + child: MyWallet( + walletId: widget.walletId, + contractAddress: widget.tokenMint, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Center( + child: Text( + "WIP", // TODO [prio=high]: Implement. + style: STextStyles.itemSubtitle(context), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} From 9f98c41ac4c504532b8642bae08c5e0b43f299d1 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 4 Nov 2025 08:19:44 -0600 Subject: [PATCH 17/80] feat(spl): add Solana wallet detection to MyTokensView --- lib/pages/token_view/my_tokens_view.dart | 248 ++++++++++++----------- 1 file changed, 130 insertions(+), 118 deletions(-) diff --git a/lib/pages/token_view/my_tokens_view.dart b/lib/pages/token_view/my_tokens_view.dart index 10e84751b..ad4fd8b6f 100644 --- a/lib/pages/token_view/my_tokens_view.dart +++ b/lib/pages/token_view/my_tokens_view.dart @@ -14,12 +14,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; +import '../../providers/global/wallets_provider.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; import '../../utilities/constants.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; +import '../../wallets/isar/providers/solana/sol_wallet_token_addresses_provider.dart'; import '../../wallets/isar/providers/wallet_info_provider.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'; @@ -28,6 +31,7 @@ import '../../widgets/stack_text_field.dart'; import '../../widgets/textfield_icon_button.dart'; import '../add_wallet_views/add_token_view/edit_wallet_tokens_view.dart'; import 'sub_widgets/my_tokens_list.dart'; +import 'sub_widgets/sol_tokens_list.dart'; class MyTokensView extends ConsumerStatefulWidget { const MyTokensView({super.key, required this.walletId}); @@ -66,80 +70,73 @@ class _MyTokensViewState extends ConsumerState { return ConditionalParent( condition: !isDesktop, - builder: - (child) => Background( - child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, - appBar: AppBar( - backgroundColor: - Theme.of(context).extension()!.background, - leading: AppBarBackButton( - onPressed: () async { - if (FocusScope.of(context).hasFocus) { - FocusScope.of(context).unfocus(); - await Future.delayed( - const Duration(milliseconds: 75), - ); - } - if (mounted) { - Navigator.of(context).pop(); - } - }, - ), - title: Text( - "${ref.watch(pWalletName(widget.walletId))} Tokens", - style: STextStyles.navBarTitle(context), - ), - actions: [ - Padding( - padding: const EdgeInsets.only( - top: 10, - bottom: 10, - right: 20, + builder: (child) => Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + backgroundColor: Theme.of( + context, + ).extension()!.background, + leading: AppBarBackButton( + onPressed: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 75)); + } + if (mounted) { + Navigator.of(context).pop(); + } + }, + ), + title: Text( + "${ref.watch(pWalletName(widget.walletId))} Tokens", + style: STextStyles.navBarTitle(context), + ), + actions: [ + Padding( + padding: const EdgeInsets.only(top: 10, bottom: 10, right: 20), + child: AspectRatio( + aspectRatio: 1, + child: AppBarIconButton( + key: const Key("addTokenAppBarIconButtonKey"), + size: 36, + shadows: const [], + color: Theme.of( + context, + ).extension()!.background, + icon: SvgPicture.asset( + Assets.svg.circlePlusFilled, + color: Theme.of( + context, + ).extension()!.topNavIconPrimary, + width: 20, + height: 20, ), - child: AspectRatio( - aspectRatio: 1, - child: AppBarIconButton( - key: const Key("addTokenAppBarIconButtonKey"), - size: 36, - shadows: const [], - color: - Theme.of( - context, - ).extension()!.background, - icon: SvgPicture.asset( - Assets.svg.circlePlusFilled, - color: - Theme.of( - context, - ).extension()!.topNavIconPrimary, - width: 20, - height: 20, - ), - onPressed: () async { - final result = await Navigator.of(context).pushNamed( - EditWalletTokensView.routeName, - arguments: widget.walletId, - ); + onPressed: () async { + final result = await Navigator.of(context).pushNamed( + EditWalletTokensView.routeName, + arguments: widget.walletId, + ); - if (mounted && result == 42) { - setState(() {}); - } - }, - ), - ), + if (mounted && result == 42) { + setState(() {}); + } + }, ), - ], - ), - body: SafeArea( - child: Padding( - padding: const EdgeInsets.only(left: 12, top: 12, right: 12), - child: child, ), ), + ], + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.only(left: 12, top: 12, right: 12), + child: child, ), ), + ), + ), child: Column( children: [ Padding( @@ -166,57 +163,55 @@ class _MyTokensViewState extends ConsumerState { _searchString = value; }); }, - style: - isDesktop - ? STextStyles.desktopTextExtraSmall( + style: isDesktop + ? STextStyles.desktopTextExtraSmall( + context, + ).copyWith( + color: Theme.of( context, - ).copyWith( - color: - Theme.of(context) - .extension()! - .textFieldActiveText, - height: 1.8, - ) - : STextStyles.field(context), - decoration: standardInputDecoration( - "Search...", - searchFieldFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - prefixIcon: Padding( - padding: EdgeInsets.symmetric( - horizontal: isDesktop ? 12 : 10, - vertical: isDesktop ? 18 : 16, - ), - child: SvgPicture.asset( - Assets.svg.search, - width: isDesktop ? 20 : 16, - height: isDesktop ? 20 : 16, - ), - ), - suffixIcon: - _searchController.text.isNotEmpty + ).extension()!.textFieldActiveText, + height: 1.8, + ) + : STextStyles.field(context), + decoration: + standardInputDecoration( + "Search...", + searchFieldFocusNode, + context, + desktopMed: isDesktop, + ).copyWith( + prefixIcon: Padding( + padding: EdgeInsets.symmetric( + horizontal: isDesktop ? 12 : 10, + vertical: isDesktop ? 18 : 16, + ), + child: SvgPicture.asset( + Assets.svg.search, + width: isDesktop ? 20 : 16, + height: isDesktop ? 20 : 16, + ), + ), + suffixIcon: _searchController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - setState(() { - _searchController.text = ""; - _searchString = ""; - }); - }, - ), - ], + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + _searchController.text = ""; + _searchString = ""; + }); + }, + ), + ], + ), ), - ), - ) + ) : null, - ), + ), ), ), ), @@ -226,10 +221,27 @@ class _MyTokensViewState extends ConsumerState { ), const SizedBox(height: 8), Expanded( - child: MyTokensList( - walletId: widget.walletId, - searchTerm: _searchString, - tokenContracts: ref.watch(pWalletTokenAddresses(widget.walletId)), + child: Builder( + builder: (context) { + final wallet = ref.watch(pWallets).getWallet(widget.walletId); + if (wallet is SolanaWallet) { + return SolanaTokensList( + walletId: widget.walletId, + searchTerm: _searchString, + tokenMints: ref.watch( + pSolanaWalletTokenAddresses(widget.walletId), + ), + ); + } else { + return MyTokensList( + walletId: widget.walletId, + searchTerm: _searchString, + tokenContracts: ref.watch( + pWalletTokenAddresses(widget.walletId), + ), + ); + } + }, ), ), ], From 71178e68ffd13ef6a8ba2431499914b72facfd9a Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 4 Nov 2025 11:47:32 -0600 Subject: [PATCH 18/80] feat(spl): add Solana token selection in wallet token editor --- .../edit_wallet_tokens_view.dart | 564 ++++++++++-------- 1 file changed, 327 insertions(+), 237 deletions(-) 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 fb37dd244..c5391bebb 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,12 +16,12 @@ 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 '../../../models/isar/models/solana/spl_token.dart'; import '../../../notifications/show_flush_bar.dart'; +import '../../../services/solana/solana_token_api.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'; @@ -31,6 +31,7 @@ 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/isar/providers/solana/sol_wallet_token_addresses_provider.dart'; import '../../../wallets/wallet/impl/ethereum_wallet.dart'; import '../../../wallets/wallet/impl/solana_wallet.dart'; import '../../../widgets/background.dart'; @@ -164,7 +165,7 @@ class _EditWalletTokensViewState extends ConsumerState { 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 tokenApi = SolanaTokenAPI(); final ownershipResult = await tokenApi.ownsToken( receivingAddress, mintAddress, @@ -218,39 +219,121 @@ class _EditWalletTokensViewState extends ConsumerState { } Future _addToken() async { - EthContract? contract; + final wallet = ref.read(pWallets).getWallet(widget.walletId); - if (isDesktop) { - contract = await showDialog( - context: context, - builder: - (context) => const DesktopDialog( - maxWidth: 580, - maxHeight: 500, - child: AddCustomTokenView(), + 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( + context: context, + builder: (context) => Container( + color: Theme.of(context).extension()!.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), + ); + }, + ), + ), + ], + ), ), - ); - } else { - final result = await Navigator.of( - context, - ).pushNamed(AddCustomTokenView.routeName); - contract = result as EthContract?; - } + ); - if (contract != null) { - await MainDB.instance.putEthContract(contract); - unawaited(ref.read(priceAnd24hChangeNotifierProvider).updatePrice()); - if (mounted) { - setState(() { - if (tokenEntities - .where((e) => e.token.address == contract!.address) - .isEmpty) { - tokenEntities.add( - AddTokenListElementData(contract!)..selected = true, - ); - tokenEntities.sort((a, b) => a.token.name.compareTo(b.token.name)); + 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)); + }); + } } - }); + } + } + } else { + // Original Ethereum token handling. + EthContract? contract; + + if (isDesktop) { + contract = await showDialog( + context: context, + builder: (context) => const DesktopDialog( + maxWidth: 580, + maxHeight: 500, + child: AddCustomTokenView(), + ), + ); + } else { + final result = await Navigator.of( + context, + ).pushNamed(AddCustomTokenView.routeName); + contract = result as EthContract?; + } + + if (contract != null) { + await MainDB.instance.putEthContract(contract); + unawaited(ref.read(priceAnd24hChangeNotifierProvider).updatePrice()); + if (mounted) { + setState(() { + if (tokenEntities + .where((e) => e.token.address == contract!.address) + .isEmpty) { + tokenEntities.add( + AddTokenListElementData(contract!)..selected = true, + ); + tokenEntities.sort((a, b) => a.token.name.compareTo(b.token.name)); + } + }); + } } } } @@ -269,8 +352,10 @@ class _EditWalletTokensViewState extends ConsumerState { tokenEntities.addAll(splTokens.map((e) => AddTokenListElementData(e))); } else { // Load Ethereum tokens (default behavior for Ethereum wallets). - final contracts = - MainDB.instance.getEthContracts().sortByName().findAllSync(); + final contracts = MainDB.instance + .getEthContracts() + .sortByName() + .findAllSync(); if (contracts.isEmpty) { contracts.addAll(DefaultTokens.list); @@ -284,7 +369,14 @@ class _EditWalletTokensViewState extends ConsumerState { tokenEntities.addAll(contracts.map((e) => AddTokenListElementData(e))); } - final walletContracts = ref.read(pWalletTokenAddresses(widget.walletId)); + // Get the appropriate token addresses based on wallet type. + List walletContracts = []; + + if (wallet is SolanaWallet) { + walletContracts = ref.read(pSolanaWalletTokenAddresses(widget.walletId)); + } else { + walletContracts = ref.read(pWalletTokenAddresses(widget.walletId)); + } final shouldMarkAsSelectedContracts = [ ...walletContracts, @@ -313,135 +405,129 @@ class _EditWalletTokensViewState extends ConsumerState { if (isDesktop) { return ConditionalParent( condition: !widget.isDesktopPopup, - builder: - (child) => DesktopScaffold( - appBar: DesktopAppBar( - isCompactHeight: false, - useSpacers: false, - leading: const AppBarBackButton(), - overlayCenter: Text( - walletName, - style: STextStyles.desktopSubtitleH2(context), - ), - trailing: - widget.contractsToMarkSelected == null - ? Padding( - padding: const EdgeInsets.only(right: 24), - child: SizedBox( - height: 56, - child: TextButton( - style: Theme.of(context) - .extension()! - .getSmallSecondaryEnabledButtonStyle(context), - onPressed: _addToken, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 30, - ), - child: Text( - "Add custom token", - style: - STextStyles.desktopButtonSmallSecondaryEnabled( - context, - ), + builder: (child) => DesktopScaffold( + appBar: DesktopAppBar( + isCompactHeight: false, + useSpacers: false, + leading: const AppBarBackButton(), + overlayCenter: Text( + walletName, + style: STextStyles.desktopSubtitleH2(context), + ), + trailing: widget.contractsToMarkSelected == null + ? Padding( + padding: const EdgeInsets.only(right: 24), + child: SizedBox( + height: 56, + child: TextButton( + style: Theme.of(context) + .extension()! + .getSmallSecondaryEnabledButtonStyle(context), + onPressed: _addToken, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 30), + child: Text( + "Add custom token", + style: + STextStyles.desktopButtonSmallSecondaryEnabled( + context, ), - ), - ), ), - ) - : null, - ), - body: SizedBox( - width: 480, - child: Column( - children: [ - const AddTokenText(isDesktop: true), - const SizedBox(height: 16), - Expanded( - child: RoundedWhiteContainer( - radiusMultiplier: 2, - padding: const EdgeInsets.only( - left: 20, - top: 20, - right: 20, - bottom: 0, ), - child: child, ), ), - const SizedBox(height: 26), - SizedBox( - height: 70, - width: 480, - child: PrimaryButton( - label: - widget.contractsToMarkSelected != null - ? "Save" - : "Next", - onPressed: onNextPressed, - ), + ) + : null, + ), + body: SizedBox( + width: 480, + child: Column( + children: [ + const AddTokenText(isDesktop: true), + const SizedBox(height: 16), + Expanded( + child: RoundedWhiteContainer( + radiusMultiplier: 2, + padding: const EdgeInsets.only( + left: 20, + top: 20, + right: 20, + bottom: 0, ), - const SizedBox(height: 32), - ], + child: child, + ), ), - ), + const SizedBox(height: 26), + SizedBox( + height: 70, + width: 480, + child: PrimaryButton( + label: widget.contractsToMarkSelected != null + ? "Save" + : "Next", + onPressed: onNextPressed, + ), + ), + const SizedBox(height: 32), + ], ), + ), + ), child: ConditionalParent( condition: widget.isDesktopPopup, - builder: - (child) => DesktopDialog( - maxHeight: 670, - child: Column( - mainAxisSize: MainAxisSize.min, + builder: (child) => DesktopDialog( + maxHeight: 670, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.only(left: 32), - child: Text( - "Edit tokens", - style: STextStyles.desktopH3(context), - ), - ), - const DesktopDialogCloseButton(), - ], - ), - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 32, - vertical: 16, - ), - child: child, - ), - ), Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: Row( - children: [ - Expanded( - child: SecondaryButton( - label: "Add custom token", - buttonHeight: ButtonHeight.l, - onPressed: _addToken, - ), - ), - const SizedBox(width: 16), - Expanded( - child: PrimaryButton( - label: "Done", - buttonHeight: ButtonHeight.l, - onPressed: onNextPressed, - ), - ), - ], + padding: const EdgeInsets.only(left: 32), + child: Text( + "Edit tokens", + style: STextStyles.desktopH3(context), ), ), - const SizedBox(height: 32), + const DesktopDialogCloseButton(), ], ), - ), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 16, + ), + child: child, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Add custom token", + buttonHeight: ButtonHeight.l, + onPressed: _addToken, + ), + ), + const SizedBox(width: 16), + Expanded( + child: PrimaryButton( + label: "Done", + buttonHeight: ButtonHeight.l, + onPressed: onNextPressed, + ), + ), + ], + ), + ), + const SizedBox(height: 32), + ], + ), + ), child: Column( children: [ ClipRRect( @@ -461,49 +547,53 @@ class _EditWalletTokensViewState extends ConsumerState { style: STextStyles.desktopTextMedium( context, ).copyWith(height: 2), - decoration: standardInputDecoration( - "Search", - _searchFocusNode, - context, - ).copyWith( - contentPadding: const EdgeInsets.symmetric(vertical: 10), - prefixIcon: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - // vertical: 20, - ), - child: SvgPicture.asset( - Assets.svg.search, - width: 24, - height: 24, - color: - Theme.of(context) + decoration: + standardInputDecoration( + "Search", + _searchFocusNode, + context, + ).copyWith( + contentPadding: const EdgeInsets.symmetric( + vertical: 10, + ), + prefixIcon: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + // vertical: 20, + ), + child: SvgPicture.asset( + Assets.svg.search, + width: 24, + height: 24, + color: Theme.of(context) .extension()! .textFieldDefaultSearchIconLeft, - ), - ), - suffixIcon: - _searchFieldController.text.isNotEmpty + ), + ), + suffixIcon: _searchFieldController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 10), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(width: 24, height: 24), - onTap: () async { - setState(() { - _searchFieldController.text = ""; - _searchTerm = ""; - }); - }, - ), - ], + padding: const EdgeInsets.only(right: 10), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon( + width: 24, + height: 24, + ), + onTap: () async { + setState(() { + _searchFieldController.text = ""; + _searchTerm = ""; + }); + }, + ), + ], + ), ), - ), - ) + ) : null, - ), + ), ), ), const SizedBox(height: 12), @@ -522,8 +612,9 @@ class _EditWalletTokensViewState extends ConsumerState { } else { return Background( child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, + backgroundColor: Theme.of( + context, + ).extension()!.background, appBar: AppBar( leading: AppBarBackButton( onPressed: () { @@ -538,14 +629,14 @@ class _EditWalletTokensViewState extends ConsumerState { child: AppBarIconButton( size: 36, shadows: const [], - color: - Theme.of(context).extension()!.background, + color: Theme.of( + context, + ).extension()!.background, icon: SvgPicture.asset( Assets.svg.circlePlusFilled, - color: - Theme.of( - context, - ).extension()!.topNavIconPrimary, + color: Theme.of( + context, + ).extension()!.topNavIconPrimary, width: 20, height: 20, ), @@ -575,49 +666,49 @@ class _EditWalletTokensViewState extends ConsumerState { enableSuggestions: !isDesktop, controller: _searchFieldController, focusNode: _searchFocusNode, - onChanged: - (value) => setState(() => _searchTerm = value), + onChanged: (value) => + setState(() => _searchTerm = value), style: STextStyles.field(context), - decoration: standardInputDecoration( - "Search", - _searchFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - prefixIcon: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 16, - ), - child: SvgPicture.asset( - Assets.svg.search, - width: 16, - height: 16, - ), - ), - suffixIcon: - _searchFieldController.text.isNotEmpty + decoration: + standardInputDecoration( + "Search", + _searchFocusNode, + context, + desktopMed: isDesktop, + ).copyWith( + prefixIcon: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 16, + ), + child: SvgPicture.asset( + Assets.svg.search, + width: 16, + height: 16, + ), + ), + suffixIcon: _searchFieldController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - setState(() { - _searchFieldController.text = - ""; - _searchTerm = ""; - }); - }, - ), - ], + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + _searchFieldController.text = + ""; + _searchTerm = ""; + }); + }, + ), + ], + ), ), - ), - ) + ) : null, - ), + ), ), ), const SizedBox(height: 10), @@ -630,10 +721,9 @@ class _EditWalletTokensViewState extends ConsumerState { ), const SizedBox(height: 16), PrimaryButton( - label: - widget.contractsToMarkSelected != null - ? "Save" - : "Next", + label: widget.contractsToMarkSelected != null + ? "Save" + : "Next", onPressed: onNextPressed, ), ], From d1f79a383a1361ba794b28f15644f103eefed499 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 4 Nov 2025 14:36:18 -0600 Subject: [PATCH 19/80] feat(spl): register routes for Solana token (SPL) views --- lib/route_generator.dart | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/route_generator.dart b/lib/route_generator.dart index 02f52a65d..34c0f9694 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -186,6 +186,7 @@ import 'pages_desktop_specific/desktop_exchange/desktop_exchange_view.dart'; import 'pages_desktop_specific/desktop_home_view.dart'; import 'pages_desktop_specific/mweb_utxos_view.dart'; import 'pages_desktop_specific/my_stack_view/my_stack_view.dart'; +import 'pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart'; import 'pages_desktop_specific/my_stack_view/wallet_view/desktop_token_view.dart'; import 'pages_desktop_specific/my_stack_view/wallet_view/desktop_wallet_view.dart'; import 'pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/delete_wallet_keys_popup.dart'; @@ -365,6 +366,19 @@ class RouteGenerator { } return _routeError("${settings.name} invalid args: ${args.toString()}"); + case DesktopSolTokenView.routeName: + if (args is ({String walletId, String tokenMint})) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => DesktopSolTokenView( + walletId: args.walletId, + tokenMint: args.tokenMint, + ), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + case SelectWalletForTokenView.routeName: if (args is EthTokenEntity) { return getRoute( From c72c9c159967ad4118609a1cac382c15251f019d Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 5 Nov 2025 09:15:51 -0600 Subject: [PATCH 20/80] fix(spl): handle missing Ethereum token wallet in shared components --- .../sub_widgets/desktop_receive.dart | 4 ++- .../sub_widgets/desktop_send_fee_form.dart | 24 +++++++++----- .../sub_widgets/desktop_wallet_summary.dart | 33 +++++++++++-------- 3 files changed, 38 insertions(+), 23 deletions(-) diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart index 607bf3f9c..76a86d2de 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart @@ -621,7 +621,9 @@ class _DesktopReceiveState extends ConsumerState { Row( children: [ Text( - "Your ${widget.contractAddress == null ? coin.ticker : ref.watch(pCurrentTokenWallet.select((value) => value!.tokenContract.symbol))} address", + // "Your ${widget.contractAddress == null ? coin.ticker : ref.watch(pCurrentTokenWallet.select((value) => value!.tokenContract.symbol))} address", + // TODO [prio=high]: Make the above work for Sol tokens instead of the placeholder below. + "Your ${widget.contractAddress == null ? coin.ticker : "token"} address", style: STextStyles.itemSubtitle(context), ), const Spacer(), diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart index e0dfd7fc1..9779f4036 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart @@ -211,15 +211,21 @@ class _DesktopSendFeeFormState extends ConsumerState { .estimateFeeFor(amount, feeRate); } } else { - final tokenWallet = ref.read( - pCurrentTokenWallet, - )!; - final fee = await tokenWallet - .estimateFeeFor(amount, feeRate); - ref - .read(tokenFeeSessionCacheProvider) - .average[amount] = - fee; + // TODO: Implement fee estimation for Solana tokens. + try { + final tokenWallet = ref.read( + pCurrentTokenWallet, + )!; + final fee = await tokenWallet + .estimateFeeFor(amount, feeRate); + ref + .read(tokenFeeSessionCacheProvider) + .average[amount] = + fee; + } catch (_) { + // Token wallet not available (Solana). + debugPrint("Token fee estimation not available"); + } } } return ref diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_summary.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_summary.dart index c6ad2694d..ce9134ec4 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_summary.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_summary.dart @@ -12,6 +12,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../models/balance.dart'; +import '../../../../models/isar/models/ethereum/eth_contract.dart'; import '../../../../pages/wallet_view/sub_widgets/wallet_refresh_button.dart'; import '../../../../providers/providers.dart'; import '../../../../providers/wallet/public_private_balance_state_provider.dart'; @@ -77,18 +78,24 @@ class _WDesktopWalletSummaryState extends ConsumerState { prefsChangeNotifierProvider.select((value) => value.currency), ); - final tokenContract = - widget.isToken - ? ref.watch( - pCurrentTokenWallet.select((value) => value!.tokenContract), - ) - : null; + // For Ethereum tokens, get the token contract; for Solana tokens, show placeholder. + dynamic tokenContract; + if (widget.isToken) { + try { + tokenContract = ref.watch( + pCurrentTokenWallet.select((value) => value!.tokenContract), + ); + } catch (_) { + // Solana token or token wallet not yet loaded. + tokenContract = null; + } + } final price = - widget.isToken + widget.isToken && tokenContract != null ? ref.watch( priceAnd24hChangeNotifierProvider.select( - (value) => value.getTokenPrice(tokenContract!.address), + (value) => value.getTokenPrice((tokenContract as dynamic).address as String), ), ) : ref.watch( @@ -116,11 +123,11 @@ class _WDesktopWalletSummaryState extends ConsumerState { } } else { final Balance balance = - widget.isToken + widget.isToken && tokenContract != null ? ref.watch( pTokenBalance(( walletId: walletId, - contractAddress: tokenContract!.address, + contractAddress: (tokenContract as dynamic).address as String, )), ) : ref.watch(pWalletBalance(walletId)); @@ -141,7 +148,7 @@ class _WDesktopWalletSummaryState extends ConsumerState { child: SelectableText( ref .watch(pAmountFormatter(coin)) - .format(balanceToShow, ethContract: tokenContract), + .format(balanceToShow, ethContract: tokenContract != null ? tokenContract as EthContract? : null), style: STextStyles.desktopH3(context), ), ), @@ -174,8 +181,8 @@ class _WDesktopWalletSummaryState extends ConsumerState { walletId: walletId, initialSyncStatus: widget.initialSyncStatus, tokenContractAddress: - widget.isToken - ? ref.watch(pCurrentTokenWallet)!.tokenContract.address + widget.isToken && tokenContract != null + ? (tokenContract as EthContract).address : null, ), From 66da8bb3d8291741a18ca759cd27d70a0ede66dc Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 5 Nov 2025 11:42:19 -0600 Subject: [PATCH 21/80] feat(spl): add Solana token handling in wallet send/receive view --- .../wallet_view/sub_widgets/my_wallet.dart | 113 +++++++++++------- 1 file changed, 67 insertions(+), 46 deletions(-) diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/my_wallet.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/my_wallet.dart index 9a8d94da6..9585cf9c4 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/my_wallet.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/my_wallet.dart @@ -18,6 +18,7 @@ import '../../../../pages/wallet_view/transaction_views/tx_v2/transaction_v2_lis import '../../../../providers/global/wallets_provider.dart'; import '../../../../wallets/crypto_currency/crypto_currency.dart'; import '../../../../wallets/wallet/impl/bitcoin_frost_wallet.dart'; +import '../../../../wallets/wallet/impl/solana_wallet.dart' show SolanaWallet; import '../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; import '../../../../widgets/custom_tab_view.dart'; import '../../../../widgets/desktop/secondary_button.dart'; @@ -42,6 +43,7 @@ class _MyWalletState extends ConsumerState { final titles = ["Send", "Receive"]; late final bool isEth; + late final bool isSolana; late final CryptoCurrency coin; late final bool isFrost; late final bool isMimblewimblecoin; @@ -53,6 +55,7 @@ class _MyWalletState extends ConsumerState { coin = wallet.info.coin; isFrost = wallet is BitcoinFrostWallet; isEth = coin is Ethereum; + isSolana = wallet is SolanaWallet; isMimblewimblecoin = coin is Mimblewimblecoin; if (isMimblewimblecoin) { @@ -101,58 +104,76 @@ class _MyWalletState extends ConsumerState { children: [ widget.contractAddress == null ? isFrost - ? Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, + ? Column( children: [ - Padding( - padding: const EdgeInsets.fromLTRB(0, 20, 0, 0), - child: SecondaryButton( - width: 200, - buttonHeight: ButtonHeight.l, - label: "Import sign config", - onPressed: () async { - final wallet = + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + 0, + 20, + 0, + 0, + ), + child: SecondaryButton( + width: 200, + buttonHeight: ButtonHeight.l, + label: "Import sign config", + onPressed: () async { + final wallet = + ref + .read(pWallets) + .getWallet(widget.walletId) + as BitcoinFrostWallet; ref - .read(pWallets) - .getWallet(widget.walletId) - as BitcoinFrostWallet; - ref.read(pFrostScaffoldArgs.state).state = ( - info: ( - walletName: wallet.info.name, - frostCurrency: wallet.cryptoCurrency, - ), - walletId: widget.walletId, - stepRoutes: - FrostRouteGenerator + .read(pFrostScaffoldArgs.state) + .state = ( + info: ( + walletName: wallet.info.name, + frostCurrency: + wallet.cryptoCurrency, + ), + walletId: widget.walletId, + stepRoutes: FrostRouteGenerator .signFrostTxStepRoutes, - parentNav: Navigator.of(context), - frostInterruptionDialogType: - FrostInterruptionDialogType - .transactionCreation, - callerRouteName: MyStackView.routeName, - ); - - await Navigator.of( - context, - ).pushNamed(FrostStepScaffold.routeName); - }, - ), + parentNav: Navigator.of(context), + frostInterruptionDialogType: + FrostInterruptionDialogType + .transactionCreation, + callerRouteName: + MyStackView.routeName, + ); + + await Navigator.of(context).pushNamed( + FrostStepScaffold.routeName, + ); + }, + ), + ), + ], + ), + FrostSendView( + walletId: widget.walletId, + coin: coin, ), ], - ), - FrostSendView(walletId: widget.walletId, coin: coin), - ], - ) - : Padding( - padding: const EdgeInsets.all(20), - child: DesktopSend(walletId: widget.walletId), - ) + ) + : Padding( + padding: const EdgeInsets.all(20), + child: DesktopSend(walletId: widget.walletId), + ) : Padding( - padding: const EdgeInsets.all(20), - child: DesktopTokenSend(walletId: widget.walletId), - ), + padding: const EdgeInsets.all(20), + child: isSolana + ? Center( + child: Text( + "WIP", // TODO [prio=high]: Implement. + style: Theme.of(context).textTheme.bodyMedium, + ), + ) + : DesktopTokenSend(walletId: widget.walletId), + ), Padding( padding: const EdgeInsets.all(20), child: DesktopReceive( From fcd68bef1a1609b35238fae423c34c42baeeba7d Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 5 Nov 2025 13:29:08 -0600 Subject: [PATCH 22/80] fix(spl): replace Ethereum-only transaction list with placeholder --- .../sub_widgets/desktop_sol_token_send.dart | 1114 +++++++++++++++++ 1 file changed, 1114 insertions(+) create mode 100644 lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart new file mode 100644 index 000000000..bf57331ea --- /dev/null +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart @@ -0,0 +1,1114 @@ +/* + * 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:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../models/isar/models/contact_entry.dart'; +import '../../../../models/paynym/paynym_account_lite.dart'; +import '../../../../models/send_view_auto_fill_data.dart'; +import '../../../../pages/send_view/confirm_transaction_view.dart'; +import '../../../../pages/send_view/sub_widgets/building_transaction_dialog.dart'; +import '../../../../providers/providers.dart'; +import '../../../../providers/ui/fee_rate_type_state_provider.dart'; +import '../../../../providers/ui/preview_tx_button_state_provider.dart'; +import '../../../../providers/wallet/desktop_fee_providers.dart'; +import '../../../../themes/stack_colors.dart'; +import '../../../../utilities/address_utils.dart'; +import '../../../../utilities/amount/amount.dart'; +import '../../../../utilities/amount/amount_formatter.dart'; +import '../../../../utilities/amount/amount_input_formatter.dart'; +import '../../../../utilities/amount/amount_unit.dart'; +import '../../../../utilities/clipboard_interface.dart'; +import '../../../../utilities/constants.dart'; +import '../../../../utilities/logger.dart'; +import '../../../../utilities/text_styles.dart'; +import '../../../../utilities/util.dart'; +import '../../../../wallets/crypto_currency/crypto_currency.dart'; +import '../../../../wallets/isar/providers/eth/current_token_wallet_provider.dart'; +import '../../../../wallets/isar/providers/eth/token_balance_provider.dart'; +import '../../../../wallets/models/tx_data.dart'; +import '../../../../widgets/custom_buttons/blue_text_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/qr_code_scanner_dialog.dart'; +import '../../../../widgets/desktop/secondary_button.dart'; +import '../../../../widgets/eth_fee_form.dart'; +import '../../../../widgets/icon_widgets/addressbook_icon.dart'; +import '../../../../widgets/icon_widgets/clipboard_icon.dart'; +import '../../../../widgets/icon_widgets/x_icon.dart'; +import '../../../../widgets/stack_text_field.dart'; +import '../../../../widgets/textfield_icon_button.dart'; +import '../../../desktop_home_view.dart'; +import 'address_book_address_chooser/address_book_address_chooser.dart'; +import 'desktop_send_fee_form.dart'; + +class DesktopTokenSend extends ConsumerStatefulWidget { + const DesktopTokenSend({ + super.key, + required this.walletId, + this.autoFillData, + this.clipboard = const ClipboardWrapper(), + + this.accountLite, + }); + + final String walletId; + final SendViewAutoFillData? autoFillData; + final ClipboardInterface clipboard; + final PaynymAccountLite? accountLite; + + @override + ConsumerState createState() => _DesktopTokenSendState(); +} + +class _DesktopTokenSendState extends ConsumerState { + late final String walletId; + late final CryptoCurrency coin; + late final ClipboardInterface clipboard; + + late TextEditingController sendToController; + late TextEditingController cryptoAmountController; + late TextEditingController baseAmountController; + late TextEditingController nonceController; + + late final SendViewAutoFillData? _data; + + final _addressFocusNode = FocusNode(); + final _cryptoFocus = FocusNode(); + final _baseFocus = FocusNode(); + final _nonceFocusNode = FocusNode(); + + String? _note; + + Amount? _amountToSend; + Amount? _cachedAmountToSend; + String? _address; + + bool _addressToggleFlag = false; + + bool _cryptoAmountChangeLock = false; + late VoidCallback onCryptoAmountChanged; + + EthEIP1559Fee? ethFee; + + Future previewSend() async { + final tokenWallet = ref.read(pCurrentTokenWallet)!; + + final Amount amount = _amountToSend!; + final Amount availableBalance = + ref + .read( + pTokenBalance(( + walletId: walletId, + contractAddress: tokenWallet.tokenContract.address, + )), + ) + .spendable; + + // confirm send all + if (amount == availableBalance) { + final bool? shouldSendAll = await showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return DesktopDialog( + maxWidth: 450, + maxHeight: double.infinity, + child: Padding( + padding: const EdgeInsets.only(left: 32, bottom: 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Confirm send all", + style: STextStyles.desktopH3(context), + ), + const DesktopDialogCloseButton(), + ], + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.only(right: 32), + child: Text( + "You are about to send your entire balance. Would you like to continue?", + textAlign: TextAlign.left, + style: STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith(fontSize: 18), + ), + ), + const SizedBox(height: 40), + Padding( + padding: const EdgeInsets.only(right: 32), + child: Row( + children: [ + Expanded( + child: SecondaryButton( + buttonHeight: ButtonHeight.l, + label: "Cancel", + onPressed: () { + Navigator.of(context).pop(false); + }, + ), + ), + const SizedBox(width: 16), + Expanded( + child: PrimaryButton( + buttonHeight: ButtonHeight.l, + label: "Yes", + onPressed: () { + Navigator.of(context).pop(true); + }, + ), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ); + + if (shouldSendAll == null || shouldSendAll == false) { + // cancel preview + return; + } + } + + try { + bool wasCancelled = false; + + if (mounted) { + unawaited( + showDialog( + context: context, + useSafeArea: false, + barrierDismissible: false, + builder: (context) { + return DesktopDialog( + maxWidth: 400, + maxHeight: double.infinity, + child: Padding( + padding: const EdgeInsets.all(32), + child: BuildingTransactionDialog( + coin: tokenWallet.cryptoCurrency, + isSpark: false, + onCancel: () { + wasCancelled = true; + + Navigator.of(context).pop(); + }, + ), + ), + ); + }, + ), + ); + } + + final time = Future.delayed(const Duration(milliseconds: 2500)); + + TxData txData; + Future txDataFuture; + + txDataFuture = tokenWallet.prepareSend( + txData: TxData( + recipients: [ + TxRecipient( + address: _address!, + amount: amount, + isChange: false, + addressType: + tokenWallet.cryptoCurrency.getAddressType(_address!)!, + ), + ], + feeRateType: ref.read(feeRateTypeDesktopStateProvider), + nonce: int.tryParse(nonceController.text), + ethEIP1559Fee: ethFee, + ), + ); + + final results = await Future.wait([txDataFuture, time]); + + txData = results.first as TxData; + + if (!wasCancelled && mounted) { + txData = txData.copyWith(note: _note ?? ""); + + // pop building dialog + Navigator.of(context, rootNavigator: true).pop(); + + unawaited( + showDialog( + context: context, + builder: + (context) => DesktopDialog( + maxHeight: MediaQuery.of(context).size.height - 64, + maxWidth: 580, + child: ConfirmTransactionView( + txData: txData, + walletId: walletId, + onSuccess: clearSendForm, + isTokenTx: true, + routeOnSuccessName: DesktopHomeView.routeName, + ), + ), + ), + ); + } + } catch (e) { + if (mounted) { + // pop building dialog + Navigator.of(context, rootNavigator: true).pop(); + + unawaited( + showDialog( + context: context, + builder: (context) { + return DesktopDialog( + maxWidth: 450, + maxHeight: double.infinity, + child: Padding( + padding: const EdgeInsets.only(left: 32, bottom: 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Transaction failed", + style: STextStyles.desktopH3(context), + ), + const DesktopDialogCloseButton(), + ], + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.only(right: 32), + child: SelectableText( + e.toString(), + textAlign: TextAlign.left, + style: STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith(fontSize: 18), + ), + ), + const SizedBox(height: 40), + Row( + children: [ + Expanded( + child: SecondaryButton( + buttonHeight: ButtonHeight.l, + label: "Ok", + onPressed: () { + Navigator.of( + context, + rootNavigator: true, + ).pop(); + }, + ), + ), + const SizedBox(width: 32), + ], + ), + ], + ), + ), + ); + }, + ), + ); + } + } + } + + void clearSendForm() { + sendToController.text = ""; + cryptoAmountController.text = ""; + baseAmountController.text = ""; + nonceController.text = ""; + _address = ""; + _addressToggleFlag = false; + if (mounted) { + setState(() {}); + } + } + + void _cryptoAmountChanged() async { + if (!_cryptoAmountChangeLock) { + final cryptoAmount = ref + .read(pAmountFormatter(coin)) + .tryParse( + cryptoAmountController.text, + ethContract: ref.read(pCurrentTokenWallet)!.tokenContract, + ); + + if (cryptoAmount != null) { + _amountToSend = cryptoAmount; + if (_cachedAmountToSend != null && + _cachedAmountToSend == _amountToSend) { + return; + } + _cachedAmountToSend = _amountToSend; + + final price = + ref + .read(priceAnd24hChangeNotifierProvider) + .getTokenPrice( + ref.read(pCurrentTokenWallet)!.tokenContract.address, + ) + ?.value; + + if (price != null && price > Decimal.zero) { + final String fiatAmountString = Amount.fromDecimal( + _amountToSend!.decimal * price, + fractionDigits: 2, + ).fiatString( + locale: ref.read(localeServiceChangeNotifierProvider).locale, + ); + + baseAmountController.text = fiatAmountString; + } + } else { + _amountToSend = null; + _cachedAmountToSend = null; + baseAmountController.text = ""; + } + + _updatePreviewButtonState(_address, _amountToSend); + } + } + + String? _updateInvalidAddressText(String address) { + if (_data != null && _data!.contactLabel == address) { + return null; + } + if (address.isNotEmpty && + !ref + .read(pWallets) + .getWallet(walletId) + .cryptoCurrency + .validateAddress(address)) { + return "Invalid address"; + } + return null; + } + + void _updatePreviewButtonState(String? address, Amount? amount) { + final wallet = ref.read(pWallets).getWallet(walletId); + + final isValidAddress = wallet.cryptoCurrency.validateAddress(address ?? ""); + ref.read(previewTokenTxButtonStateProvider.state).state = + (isValidAddress && amount != null && amount > Amount.zero); + } + + Future scanQr() async { + try { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 75)); + } + + final qrResult = await showDialog( + context: context, + builder: (context) => const QrCodeScannerDialog(), + ); + + if (qrResult == null) { + Logging.instance.w("Qr scanning cancelled"); + return; + } + + Logging.instance.d("qrResult content: $qrResult"); + + final paymentData = AddressUtils.parsePaymentUri( + qrResult, + logging: Logging.instance, + ); + + Logging.instance.d("qrResult parsed: $paymentData"); + + if (paymentData != null && + paymentData.coin?.uriScheme == coin.uriScheme) { + // auto fill address + _address = paymentData.address.trim(); + sendToController.text = _address!; + + // autofill notes field + if (paymentData.message != null) { + _note = paymentData.message!; + } else if (paymentData.label != null) { + _note = paymentData.label!; + } + + // autofill amount field + if (paymentData.amount != null) { + final Amount amount = Decimal.parse(paymentData.amount!).toAmount( + fractionDigits: + ref.read(pCurrentTokenWallet)!.tokenContract.decimals, + ); + cryptoAmountController.text = ref + .read(pAmountFormatter(coin)) + .format(amount, withUnitName: false); + + _amountToSend = amount; + } + + _updatePreviewButtonState(_address, _amountToSend); + setState(() { + _addressToggleFlag = sendToController.text.isNotEmpty; + }); + + // now check for non standard encoded basic address + } else { + _address = qrResult.split("\n").first.trim(); + sendToController.text = _address ?? ""; + + _updatePreviewButtonState(_address, _amountToSend); + setState(() { + _addressToggleFlag = sendToController.text.isNotEmpty; + }); + } + } on PlatformException catch (e, s) { + // here we ignore the exception caused by not giving permission + // to use the camera to scan a qr code + Logging.instance.w( + "Failed to get camera permissions while trying to scan qr code in SendView: ", + error: e, + stackTrace: s, + ); + } + } + + Future pasteAddress() async { + final ClipboardData? data = await clipboard.getData(Clipboard.kTextPlain); + if (data?.text != null && data!.text!.isNotEmpty) { + String content = data.text!.trim(); + if (content.contains("\n")) { + content = content.substring(0, content.indexOf("\n")); + } + + sendToController.text = content; + _address = content; + + _updatePreviewButtonState(_address, _amountToSend); + setState(() { + _addressToggleFlag = sendToController.text.isNotEmpty; + }); + } + } + + void fiatTextFieldOnChanged(String baseAmountString) { + final int tokenDecimals = + ref.read(pCurrentTokenWallet)!.tokenContract.decimals; + + if (baseAmountString.isNotEmpty && + baseAmountString != "." && + baseAmountString != ",") { + final baseAmount = + baseAmountString.contains(",") + ? Decimal.parse( + baseAmountString.replaceFirst(",", "."), + ).toAmount(fractionDigits: 2) + : Decimal.parse(baseAmountString).toAmount(fractionDigits: 2); + + final Decimal? _price = + ref + .read(priceAnd24hChangeNotifierProvider) + .getTokenPrice( + ref.read(pCurrentTokenWallet)!.tokenContract.address, + ) + ?.value; + + if (_price == null || _price == Decimal.zero) { + _amountToSend = Decimal.zero.toAmount(fractionDigits: tokenDecimals); + } else { + _amountToSend = + baseAmount <= Amount.zero + ? Decimal.zero.toAmount(fractionDigits: tokenDecimals) + : (baseAmount.decimal / _price) + .toDecimal(scaleOnInfinitePrecision: tokenDecimals) + .toAmount(fractionDigits: tokenDecimals); + } + if (_cachedAmountToSend != null && _cachedAmountToSend == _amountToSend) { + return; + } + _cachedAmountToSend = _amountToSend; + + final amountString = ref + .read(pAmountFormatter(coin)) + .format( + _amountToSend!, + withUnitName: false, + ethContract: ref.read(pCurrentTokenWallet)!.tokenContract, + ); + + _cryptoAmountChangeLock = true; + cryptoAmountController.text = amountString; + _cryptoAmountChangeLock = false; + } else { + _amountToSend = Decimal.zero.toAmount(fractionDigits: tokenDecimals); + _cryptoAmountChangeLock = true; + cryptoAmountController.text = ""; + _cryptoAmountChangeLock = false; + } + + _updatePreviewButtonState(_address, _amountToSend); + } + + Future sendAllTapped() async { + cryptoAmountController.text = ref + .read( + pTokenBalance(( + walletId: walletId, + contractAddress: + ref.read(pCurrentTokenWallet)!.tokenContract.address, + )), + ) + .spendable + .decimal + .toStringAsFixed(ref.read(pCurrentTokenWallet)!.tokenContract.decimals); + } + + @override + void initState() { + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.refresh(tokenFeeSessionCacheProvider); + ref.read(previewTokenTxButtonStateProvider.state).state = false; + }); + + // _calculateFeesFuture = calculateFees(0); + _data = widget.autoFillData; + walletId = widget.walletId; + coin = ref.read(pWallets).getWallet(walletId).info.coin; + clipboard = widget.clipboard; + + sendToController = TextEditingController(); + cryptoAmountController = TextEditingController(); + baseAmountController = TextEditingController(); + nonceController = TextEditingController(); + // feeController = TextEditingController(); + + onCryptoAmountChanged = _cryptoAmountChanged; + cryptoAmountController.addListener(onCryptoAmountChanged); + + if (_data != null) { + if (_data!.amount != null) { + cryptoAmountController.text = _data!.amount!.toString(); + } + sendToController.text = _data!.contactLabel; + _address = _data!.address; + _addressToggleFlag = true; + } + + _cryptoFocus.addListener(() { + if (!_cryptoFocus.hasFocus && !_baseFocus.hasFocus) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_amountToSend == null) { + ref.refresh(sendAmountProvider); + } else { + ref.read(sendAmountProvider.state).state = _amountToSend!; + } + }); + } + }); + + _baseFocus.addListener(() { + if (!_cryptoFocus.hasFocus && !_baseFocus.hasFocus) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_amountToSend == null) { + ref.refresh(sendAmountProvider); + } else { + ref.read(sendAmountProvider.state).state = _amountToSend!; + } + }); + } + }); + + super.initState(); + } + + @override + void dispose() { + cryptoAmountController.removeListener(onCryptoAmountChanged); + + sendToController.dispose(); + cryptoAmountController.dispose(); + baseAmountController.dispose(); + nonceController.dispose(); + // feeController.dispose(); + + _addressFocusNode.dispose(); + _cryptoFocus.dispose(); + _baseFocus.dispose(); + _nonceFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + debugPrint("BUILD: $runtimeType"); + + final tokenContract = ref.watch(pCurrentTokenWallet)!.tokenContract; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + if (coin is Firo) + Text( + "Send from", + style: STextStyles.desktopTextExtraSmall(context).copyWith( + color: + Theme.of( + context, + ).extension()!.textFieldActiveSearchIconRight, + ), + textAlign: TextAlign.left, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Amount", + style: STextStyles.desktopTextExtraSmall(context).copyWith( + color: + Theme.of( + context, + ).extension()!.textFieldActiveSearchIconRight, + ), + textAlign: TextAlign.left, + ), + CustomTextButton( + text: "Send all ${tokenContract.symbol}", + onTap: sendAllTapped, + ), + ], + ), + const SizedBox(height: 10), + TextField( + autocorrect: Util.isDesktop ? false : true, + enableSuggestions: Util.isDesktop ? false : true, + style: STextStyles.smallMed14(context).copyWith( + color: Theme.of(context).extension()!.textDark, + ), + key: const Key("amountInputFieldCryptoTextFieldKey"), + controller: cryptoAmountController, + focusNode: _cryptoFocus, + keyboardType: + Util.isDesktop + ? null + : const TextInputType.numberWithOptions( + signed: false, + decimal: true, + ), + textAlign: TextAlign.right, + inputFormatters: [ + AmountInputFormatter( + decimals: tokenContract.decimals, + unit: ref.watch(pAmountUnit(coin)), + locale: ref.watch( + localeServiceChangeNotifierProvider.select( + (value) => value.locale, + ), + ), + ), + // regex to validate a crypto amount with 8 decimal places + // TextInputFormatter.withFunction((oldValue, newValue) => RegExp( + // _kCryptoAmountRegex.replaceAll( + // "0,8", + // "0,${tokenContract.decimals}", + // ), + // ).hasMatch(newValue.text) + // ? newValue + // : oldValue), + ], + onChanged: (newValue) {}, + decoration: InputDecoration( + contentPadding: const EdgeInsets.only( + top: 22, + right: 12, + bottom: 22, + ), + hintText: "0", + hintStyle: STextStyles.desktopTextExtraSmall(context).copyWith( + color: + Theme.of( + context, + ).extension()!.textFieldDefaultText, + ), + prefixIcon: FittedBox( + fit: BoxFit.scaleDown, + child: Padding( + padding: const EdgeInsets.all(12), + child: Text( + ref.watch(pAmountUnit(coin)).unitForContract(tokenContract), + style: STextStyles.smallMed14(context).copyWith( + color: + Theme.of( + context, + ).extension()!.accentColorDark, + ), + ), + ), + ), + ), + ), + if (ref.watch( + prefsChangeNotifierProvider.select((s) => s.externalCalls), + )) + const SizedBox(height: 10), + if (ref.watch( + prefsChangeNotifierProvider.select((s) => s.externalCalls), + )) + TextField( + autocorrect: Util.isDesktop ? false : true, + enableSuggestions: Util.isDesktop ? false : true, + style: STextStyles.smallMed14(context).copyWith( + color: Theme.of(context).extension()!.textDark, + ), + key: const Key("amountInputFieldFiatTextFieldKey"), + controller: baseAmountController, + focusNode: _baseFocus, + keyboardType: + Util.isDesktop + ? null + : const TextInputType.numberWithOptions( + signed: false, + decimal: true, + ), + textAlign: TextAlign.right, + inputFormatters: [ + AmountInputFormatter( + decimals: 2, + locale: ref.watch( + localeServiceChangeNotifierProvider.select( + (value) => value.locale, + ), + ), + ), + // // regex to validate a fiat amount with 2 decimal places + // TextInputFormatter.withFunction((oldValue, newValue) => + // RegExp(r'^([0-9]*[,.]?[0-9]{0,2}|[,.][0-9]{0,2})$') + // .hasMatch(newValue.text) + // ? newValue + // : oldValue), + ], + onChanged: fiatTextFieldOnChanged, + decoration: InputDecoration( + contentPadding: const EdgeInsets.only( + top: 22, + right: 12, + bottom: 22, + ), + hintText: "0", + hintStyle: STextStyles.desktopTextExtraSmall(context).copyWith( + color: + Theme.of( + context, + ).extension()!.textFieldDefaultText, + ), + prefixIcon: FittedBox( + fit: BoxFit.scaleDown, + child: Padding( + padding: const EdgeInsets.all(12), + child: Text( + ref.watch( + prefsChangeNotifierProvider.select( + (value) => value.currency, + ), + ), + style: STextStyles.smallMed14(context).copyWith( + color: + Theme.of( + context, + ).extension()!.accentColorDark, + ), + ), + ), + ), + ), + ), + const SizedBox(height: 20), + Text( + "Send to", + style: STextStyles.desktopTextExtraSmall(context).copyWith( + color: + Theme.of( + context, + ).extension()!.textFieldActiveSearchIconRight, + ), + textAlign: TextAlign.left, + ), + const SizedBox(height: 10), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + minLines: 1, + maxLines: 5, + key: const Key("sendViewAddressFieldKey"), + controller: sendToController, + readOnly: false, + autocorrect: false, + enableSuggestions: false, + // inputFormatters: [ + // FilteringTextInputFormatter.allow( + // RegExp("[a-zA-Z0-9]{34}")), + // ], + toolbarOptions: const ToolbarOptions( + copy: false, + cut: false, + paste: true, + selectAll: false, + ), + onChanged: (newValue) { + _address = newValue; + _updatePreviewButtonState(_address, _amountToSend); + + setState(() { + _addressToggleFlag = newValue.isNotEmpty; + }); + }, + focusNode: _addressFocusNode, + style: STextStyles.desktopTextExtraSmall(context).copyWith( + color: + Theme.of( + context, + ).extension()!.textFieldActiveText, + height: 1.8, + ), + decoration: standardInputDecoration( + "Enter ${tokenContract.symbol} address", + _addressFocusNode, + context, + desktopMed: true, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 11, + bottom: 12, + right: 5, + ), + suffixIcon: Padding( + padding: + sendToController.text.isEmpty + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _addressToggleFlag + ? TextFieldIconButton( + key: const Key( + "sendTokenViewClearAddressFieldButtonKey", + ), + onTap: () { + sendToController.text = ""; + _address = ""; + _updatePreviewButtonState( + _address, + _amountToSend, + ); + setState(() { + _addressToggleFlag = false; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + key: const Key( + "sendTokenViewPasteAddressFieldButtonKey", + ), + onTap: pasteAddress, + child: + sendToController.text.isEmpty + ? const ClipboardIcon() + : const XIcon(), + ), + if (sendToController.text.isEmpty) + TextFieldIconButton( + key: const Key("sendTokenViewAddressBookButtonKey"), + onTap: () async { + final entry = await showDialog< + ContactAddressEntry? + >( + context: context, + builder: + (context) => DesktopDialog( + maxWidth: 696, + maxHeight: 600, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only( + left: 32, + ), + child: Text( + "Address book", + style: STextStyles.desktopH3( + context, + ), + ), + ), + const DesktopDialogCloseButton(), + ], + ), + Expanded( + child: AddressBookAddressChooser( + coin: coin, + ), + ), + ], + ), + ), + ); + + if (entry != null) { + sendToController.text = + entry.other ?? entry.label; + + _address = entry.address; + + _updatePreviewButtonState( + _address, + _amountToSend, + ); + + setState(() { + _addressToggleFlag = true; + }); + } + }, + child: const AddressBookIcon(), + ), + ], + ), + ), + ), + ), + ), + ), + Builder( + builder: (_) { + final error = _updateInvalidAddressText(_address ?? ""); + + if (error == null || error.isEmpty) { + return Container(); + } else { + return Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.only(left: 12.0, top: 4.0), + child: Text( + error, + textAlign: TextAlign.left, + style: STextStyles.label(context).copyWith( + color: + Theme.of(context).extension()!.textError, + ), + ), + ), + ); + } + }, + ), + const SizedBox(height: 20), + DesktopSendFeeForm( + walletId: walletId, + isToken: true, + onCustomFeeSliderChanged: (value) => {}, + onCustomFeeOptionChanged: (value) { + ethFee = null; + }, + onCustomEip1559FeeOptionChanged: (value) => ethFee = value, + ), + const SizedBox(height: 20), + Text( + "Nonce", + style: STextStyles.desktopTextExtraSmall(context).copyWith( + color: + Theme.of( + context, + ).extension()!.textFieldActiveSearchIconRight, + ), + textAlign: TextAlign.left, + ), + const SizedBox(height: 10), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + minLines: 1, + maxLines: 1, + key: const Key("sendViewNonceFieldKey"), + controller: nonceController, + readOnly: false, + autocorrect: false, + enableSuggestions: false, + keyboardType: const TextInputType.numberWithOptions(), + focusNode: _nonceFocusNode, + style: STextStyles.desktopTextExtraSmall(context).copyWith( + color: + Theme.of( + context, + ).extension()!.textFieldActiveText, + height: 1.8, + ), + decoration: standardInputDecoration( + "Leave empty to auto select nonce", + _nonceFocusNode, + context, + desktopMed: true, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 11, + bottom: 12, + right: 5, + ), + ), + ), + ), + const SizedBox(height: 36), + PrimaryButton( + buttonHeight: ButtonHeight.l, + label: "Preview send", + enabled: ref.watch(previewTokenTxButtonStateProvider.state).state, + onPressed: + ref.watch(previewTokenTxButtonStateProvider.state).state + ? previewSend + : null, + ), + ], + ); + } +} From c47a93f6cfb6057d100a60d8361bfd62439b0191 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 6 Nov 2025 12:22:36 -0600 Subject: [PATCH 23/80] fix(spl): Solana token specific views for mobile (WIP) --- lib/pages/token_view/sol_token_view.dart | 35 +- .../sub_widgets/token_summary_sol.dart | 313 ++++++++++++++++++ 2 files changed, 346 insertions(+), 2 deletions(-) create mode 100644 lib/pages/token_view/sub_widgets/token_summary_sol.dart diff --git a/lib/pages/token_view/sol_token_view.dart b/lib/pages/token_view/sol_token_view.dart index 7ff9570d7..94e2bd8c8 100644 --- a/lib/pages/token_view/sol_token_view.dart +++ b/lib/pages/token_view/sol_token_view.dart @@ -17,11 +17,13 @@ import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; import '../../utilities/constants.dart'; import '../../utilities/text_styles.dart'; +import '../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; +import '../../wallets/wallet/impl/sub_wallets/solana_token_wallet.dart'; import '../../widgets/background.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../widgets/custom_buttons/blue_text_button.dart'; import '../../widgets/icon_widgets/sol_token_icon.dart'; -import 'sub_widgets/token_summary.dart'; +import 'sub_widgets/token_summary_sol.dart'; import 'sub_widgets/token_transaction_list_widget.dart'; /// [eventBus] should only be set during testing. @@ -52,9 +54,37 @@ class _SolTokenViewState extends ConsumerState { void initState() { // TODO: Integrate Solana token refresh status when available. initialSyncStatus = WalletSyncStatus.synced; + + // Initialize the Solana token wallet provider with mock data. + // This sets up the pCurrentSolanaTokenWallet provider so that + // SolanaTokenSummary can access the token wallet information. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _initializeSolanaTokenWallet(); + } + }); + super.initState(); } + /// Initialize the Solana token wallet for this token view. + /// + /// Creates a mock SolanaTokenWallet and sets it as the current token wallet + /// in the provider so that UI widgets can access it. + void _initializeSolanaTokenWallet() { + // Create a mock Solana token wallet with placeholder data + // In a real implementation, this would load actual token data from the Solana API + final solanaTokenWallet = SolanaTokenWallet( + tokenMint: widget.tokenMint, + tokenName: "Solana Token", // TODO: Load actual token name. + tokenSymbol: "SOL", // TODO: Load actual token symbol. + tokenDecimals: 6, // TODO: Load actual token decimals. + ); + + // Set the wallet in the provider so that it can be accessed by widgets. + ref.read(solanaTokenServiceStateProvider.state).state = solanaTokenWallet; + } + @override void dispose() { super.dispose(); @@ -150,8 +180,9 @@ class _SolTokenViewState extends ConsumerState { const SizedBox(height: 10), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), - child: TokenSummary( + child: SolanaTokenSummary( walletId: widget.walletId, + tokenMint: widget.tokenMint, initialSyncStatus: initialSyncStatus, ), ), diff --git a/lib/pages/token_view/sub_widgets/token_summary_sol.dart b/lib/pages/token_view/sub_widgets/token_summary_sol.dart new file mode 100644 index 000000000..707906cdd --- /dev/null +++ b/lib/pages/token_view/sub_widgets/token_summary_sol.dart @@ -0,0 +1,313 @@ +/* + * 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 'dart:io'; + +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../../../providers/global/locale_provider.dart'; +import '../../../providers/global/prefs_provider.dart'; +import '../../../services/event_bus/events/global/wallet_sync_status_changed_event.dart'; +import '../../../themes/stack_colors.dart'; +import '../../../utilities/amount/amount.dart'; +import '../../../utilities/amount/amount_formatter.dart'; +import '../../../utilities/assets.dart'; +import '../../../utilities/constants.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../wallets/crypto_currency/crypto_currency.dart'; +import '../../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; +import '../../../wallets/isar/providers/solana/sol_token_balance_provider.dart'; +import '../../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../../widgets/coin_ticker_tag.dart'; +import '../../../widgets/conditional_parent.dart'; +import '../../../widgets/rounded_container.dart'; +import '../../wallet_view/sub_widgets/wallet_refresh_button.dart'; + +/// Solana-specific token summary widget. +/// +/// Displays token balance, wallet name, and available actions for Solana tokens. +class SolanaTokenSummary extends ConsumerWidget { + const SolanaTokenSummary({ + super.key, + required this.walletId, + required this.tokenMint, + required this.initialSyncStatus, + }); + + final String walletId; + final String tokenMint; + final WalletSyncStatus initialSyncStatus; + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Get the Solana token wallet. + final tokenWallet = ref.watch(pCurrentSolanaTokenWallet); + + // If wallet is not initialized, show a placeholder. + if (tokenWallet == null) { + return RoundedContainer( + color: Theme.of(context).extension()!.tokenSummaryBG, + padding: const EdgeInsets.all(24), + child: Center( + child: Text( + "Loading token data...", + style: STextStyles.subtitle500(context).copyWith( + color: + Theme.of(context).extension()!.tokenSummaryTextPrimary, + ), + ), + ), + ); + } + + final balance = ref.watch( + pSolanaTokenBalance((walletId: walletId, tokenMint: tokenMint)), + ); + + Decimal? price; + if (ref.watch(prefsChangeNotifierProvider.select((s) => s.externalCalls))) { + // TODO: Implement price fetching for Solana tokens. + // For now, prices are not fetched for Solana tokens. + price = null; + } + + return Stack( + children: [ + RoundedContainer( + color: Theme.of(context).extension()!.tokenSummaryBG, + padding: const EdgeInsets.all(24), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SvgPicture.asset( + Assets.svg.walletDesktop, + color: Theme.of( + context, + ).extension()!.tokenSummaryTextSecondary, + width: 12, + height: 12, + ), + const SizedBox(width: 6), + Text( + ref.watch(pWalletName(walletId)), + style: STextStyles.w500_12(context).copyWith( + color: Theme.of( + context, + ).extension()!.tokenSummaryTextSecondary, + ), + ), + ], + ), + const SizedBox(height: 6), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + ref + .watch( + pAmountFormatter( + Solana(CryptoCurrencyNetwork.main), + ), + ) + .format(balance.total), + style: STextStyles.pageTitleH1(context).copyWith( + color: Theme.of( + context, + ).extension()!.tokenSummaryTextPrimary, + ), + ), + const SizedBox(width: 10), + CoinTickerTag( + ticker: tokenWallet.tokenSymbol, + ), + ], + ), + if (price != null) const SizedBox(height: 6), + if (price != null) + Text( + "${(balance.total.decimal * price).toAmount(fractionDigits: 2).fiatString(locale: ref.watch(localeServiceChangeNotifierProvider.select((value) => value.locale)))} ${ref.watch(prefsChangeNotifierProvider.select((value) => value.currency))}", + style: STextStyles.subtitle500(context).copyWith( + color: Theme.of( + context, + ).extension()!.tokenSummaryTextPrimary, + ), + ), + const SizedBox(height: 20), + SolanaTokenWalletOptions( + walletId: walletId, + tokenMint: tokenMint, + ), + ], + ), + ), + Positioned( + top: 10, + right: 10, + child: WalletRefreshButton( + walletId: walletId, + initialSyncStatus: initialSyncStatus, + tokenContractAddress: tokenMint, + overrideIconColor: + Theme.of(context).extension()!.topNavIconPrimary, + ), + ), + ], + ); + } +} + +/// Solana token wallet action buttons (Send, Receive, etc.). +class SolanaTokenWalletOptions extends ConsumerWidget { + const SolanaTokenWalletOptions({ + super.key, + required this.walletId, + required this.tokenMint, + }); + + final String walletId; + final String tokenMint; + + @override + Widget build(BuildContext context, WidgetRef ref) { + // TODO: Use prefs for enabling/disabling exchange features when implemented for Solana. + // final prefs = ref.watch(prefsChangeNotifierProvider); + // final showExchange = prefs.enableExchange; + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TokenOptionsButton( + onPressed: () { + // TODO: Navigate to Solana token receive view. + // Navigator.of(context).pushNamed( + // SolTokenReceiveView.routeName, + // arguments: Tuple2(walletId, tokenMint), + // ); + }, + subLabel: "Receive", + iconAssetPathSVG: Assets.svg.arrowDownLeft, + ), + const SizedBox(width: 16), + TokenOptionsButton( + onPressed: () { + // TODO: Navigate to Solana token send view. + // Navigator.of(context).pushNamed( + // SolTokenSendView.routeName, + // arguments: Tuple2(walletId, tokenMint), + // ); + }, + subLabel: "Send", + iconAssetPathSVG: Assets.svg.arrowUpRight, + ), + // TODO: Add swap and buy buttons when Solana token swap/buy views are implemented. + // if (AppConfig.hasFeature(AppFeature.swap) && showExchange) + // const SizedBox(width: 16), + // if (AppConfig.hasFeature(AppFeature.swap) && showExchange) + // TokenOptionsButton( + // onPressed: () => _onExchangePressed(context), + // subLabel: "Swap", + // iconAssetPathSVG: ref.watch( + // themeProvider.select((value) => value.assets.exchange), + // ), + // ), + ], + ); + } +} + +/// A button for token wallet options (Send, Receive, Swap, Buy). +class TokenOptionsButton extends StatelessWidget { + const TokenOptionsButton({ + super.key, + required this.onPressed, + required this.subLabel, + required this.iconAssetPathSVG, + }); + + final VoidCallback onPressed; + final String subLabel; + final String iconAssetPathSVG; + + @override + Widget build(BuildContext context) { + final iconSize = subLabel == "Send" || subLabel == "Receive" ? 12.0 : 24.0; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + RawMaterialButton( + fillColor: + Theme.of(context).extension()!.tokenSummaryButtonBG, + elevation: 0, + focusElevation: 0, + hoverElevation: 0, + highlightElevation: 0, + constraints: const BoxConstraints(), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + onPressed: onPressed, + child: Padding( + padding: const EdgeInsets.all(10), + child: ConditionalParent( + condition: iconSize < 24, + builder: + (child) => RoundedContainer( + padding: const EdgeInsets.all(6), + color: Theme.of(context) + .extension()! + .tokenSummaryIcon + .withOpacity(0.4), + radiusMultiplier: 10, + child: Center(child: child), + ), + child: + iconAssetPathSVG.startsWith("assets/") + ? SvgPicture.asset( + iconAssetPathSVG, + color: + Theme.of( + context, + ).extension()!.tokenSummaryIcon, + width: iconSize, + height: iconSize, + ) + : SvgPicture.file( + File(iconAssetPathSVG), + color: + Theme.of( + context, + ).extension()!.tokenSummaryIcon, + width: iconSize, + height: iconSize, + ), + ), + ), + ), + const SizedBox(height: 6), + Text( + subLabel, + style: STextStyles.w500_12(context).copyWith( + color: + Theme.of( + context, + ).extension()!.tokenSummaryTextPrimary, + ), + ), + ], + ); + } +} \ No newline at end of file From bf7dacbc53eac9d762a99697effe1a1b1e06e581 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 6 Nov 2025 12:34:20 -0600 Subject: [PATCH 24/80] fix(spl): Solana token specific transaction list widget --- .../edit_wallet_tokens_view.dart | 33 ++-- lib/pages/token_view/sol_token_view.dart | 4 +- .../token_transaction_list_widget_sol.dart | 179 ++++++++++++++++++ lib/wallets/wallet/impl/solana_wallet.dart | 7 + 4 files changed, 208 insertions(+), 15 deletions(-) create mode 100644 lib/pages/token_view/sub_widgets/token_transaction_list_widget_sol.dart 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 c5391bebb..1602aa467 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 @@ -163,24 +163,31 @@ class _EditWalletTokensViewState extends ConsumerState { // 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. + // Initialize the RPC client for the SolanaTokenAPI. final tokenApi = SolanaTokenAPI(); - final ownershipResult = await tokenApi.ownsToken( - receivingAddress, - mintAddress, - ); + final rpcClient = wallet.getRpcClient(); + + if (rpcClient != null) { + tokenApi.initializeRpcClient(rpcClient); + + final ownershipResult = await tokenApi.ownsToken( + receivingAddress, + mintAddress, + ); - if (ownershipResult.isSuccess) { - if (ownershipResult.value == true) { - debugPrint('OWNS token - token account found'); + 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('DOES NOT own token - no token account found'); + debugPrint( + 'Error checking ownership: ${ownershipResult.exception}', + ); } } else { - debugPrint( - 'Error checking ownership: ${ownershipResult.exception}', - ); + debugPrint('Warning: RPC client not initialized for wallet'); } } catch (e) { debugPrint('Exception checking ownership: $e'); diff --git a/lib/pages/token_view/sol_token_view.dart b/lib/pages/token_view/sol_token_view.dart index 94e2bd8c8..44ce450ca 100644 --- a/lib/pages/token_view/sol_token_view.dart +++ b/lib/pages/token_view/sol_token_view.dart @@ -24,7 +24,7 @@ import '../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../widgets/custom_buttons/blue_text_button.dart'; import '../../widgets/icon_widgets/sol_token_icon.dart'; import 'sub_widgets/token_summary_sol.dart'; -import 'sub_widgets/token_transaction_list_widget.dart'; +import 'sub_widgets/token_transaction_list_widget_sol.dart'; /// [eventBus] should only be set during testing. class SolTokenView extends ConsumerStatefulWidget { @@ -242,7 +242,7 @@ class _SolTokenViewState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded( - child: TokenTransactionsList( + child: SolanaTokenTransactionsList( walletId: widget.walletId, ), ), diff --git a/lib/pages/token_view/sub_widgets/token_transaction_list_widget_sol.dart b/lib/pages/token_view/sub_widgets/token_transaction_list_widget_sol.dart new file mode 100644 index 000000000..d3640e841 --- /dev/null +++ b/lib/pages/token_view/sub_widgets/token_transaction_list_widget_sol.dart @@ -0,0 +1,179 @@ +/* + * 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 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:isar_community/isar.dart'; + +import '../../../models/isar/models/blockchain_data/v2/transaction_v2.dart'; +import '../../wallet_view/sub_widgets/no_transactions_found.dart'; +import '../../wallet_view/transaction_views/tx_v2/transaction_v2_list_item.dart'; +import '../../../providers/db/main_db_provider.dart'; +import '../../../providers/global/wallets_provider.dart'; +import '../../../themes/stack_colors.dart'; +import '../../../utilities/constants.dart'; +import '../../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; +import '../../../widgets/loading_indicator.dart'; + +/// Solana-specific transaction list widget. +/// +/// Displays transactions for a Solana token using the Solana token wallet provider. +class SolanaTokenTransactionsList extends ConsumerStatefulWidget { + const SolanaTokenTransactionsList({ + super.key, + required this.walletId, + }); + + final String walletId; + + @override + ConsumerState createState() => + _SolanaTransactionsListState(); +} + +class _SolanaTransactionsListState extends ConsumerState { + late final int minConfirms; + + bool _hasLoaded = false; + List _transactions = []; + + late final StreamSubscription> _subscription; + late final Query _query; + + BorderRadius get _borderRadiusFirst { + return BorderRadius.only( + topLeft: Radius.circular( + Constants.size.circularBorderRadius, + ), + topRight: Radius.circular( + Constants.size.circularBorderRadius, + ), + ); + } + + BorderRadius get _borderRadiusLast { + return BorderRadius.only( + bottomLeft: Radius.circular( + Constants.size.circularBorderRadius, + ), + bottomRight: Radius.circular( + Constants.size.circularBorderRadius, + ), + ); + } + + @override + void initState() { + minConfirms = ref + .read(pWallets) + .getWallet(widget.walletId) + .cryptoCurrency + .minConfirms; + + // Get transaction filter from Solana token wallet if available. + final solanaTokenWallet = ref.read(pCurrentSolanaTokenWallet); + FilterOperation? transactionFilter; + + if (solanaTokenWallet != null) { + transactionFilter = solanaTokenWallet.transactionFilterOperation; + } + + _query = ref.read(mainDBProvider).isar.transactionV2s.buildQuery( + whereClauses: [ + IndexWhereClause.equalTo( + indexName: 'walletId', + value: [widget.walletId], + ), + ], + filter: transactionFilter, + sortBy: [ + const SortProperty( + property: "timestamp", + sort: Sort.desc, + ), + ], + ); + + _subscription = _query.watch().listen((event) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _transactions = event; + }); + } + }); + }); + super.initState(); + } + + @override + void dispose() { + _subscription.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final wallet = + ref.watch(pWallets.select((value) => value.getWallet(widget.walletId))); + + return FutureBuilder( + future: _query.findAll(), + builder: (fbContext, AsyncSnapshot> snapshot) { + if (snapshot.connectionState == ConnectionState.done && + snapshot.hasData) { + if (!_hasLoaded) { + _hasLoaded = true; + _transactions = snapshot.data ?? []; + } + + if (_transactions.isEmpty) { + return const NoTransActionsFound(); + } + + return CustomScrollView( + slivers: [ + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + return TxListItem( + key: Key( + "solanaTokenTransactionV2ListItemKey_${_transactions[index].txid}", + ), + tx: _transactions[index], + coin: wallet.cryptoCurrency, + radius: index == 0 + ? _borderRadiusFirst + : index == _transactions.length - 1 + ? _borderRadiusLast + : null, + ); + }, + childCount: _transactions.length, + ), + ), + ], + ); + } + + return Center( + child: Container( + color: Theme.of(context).extension()!.background, + child: const LoadingIndicator( + width: 100, + height: 100, + ), + ), + ); + }, + ); + } +} diff --git a/lib/wallets/wallet/impl/solana_wallet.dart b/lib/wallets/wallet/impl/solana_wallet.dart index c88d99562..db9601829 100644 --- a/lib/wallets/wallet/impl/solana_wallet.dart +++ b/lib/wallets/wallet/impl/solana_wallet.dart @@ -35,6 +35,13 @@ class SolanaWallet extends Bip39Wallet { RpcClient? _rpcClient; // The Solana RpcClient. + /// Get the RPC client for this wallet. + /// + /// This is used by services like SolanaTokenAPI that need to make RPC calls. + RpcClient? getRpcClient() { + return _rpcClient; + } + Future _getKeyPair() async { return Ed25519HDKeyPair.fromMnemonic( await getMnemonic(), From c2ee6ecfe3431f66c5a9a90891dfa07b29b0995c Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 6 Nov 2025 12:56:42 -0600 Subject: [PATCH 25/80] ui(spl): SPL token icon --- lib/models/isar/stack_theme.dart | 2 ++ lib/pages/token_view/sub_widgets/sol_token_select_item.dart | 4 ++-- lib/themes/coin_icon_provider.dart | 2 ++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/models/isar/stack_theme.dart b/lib/models/isar/stack_theme.dart index 476494d92..68e0f047b 100644 --- a/lib/models/isar/stack_theme.dart +++ b/lib/models/isar/stack_theme.dart @@ -1944,6 +1944,7 @@ class ThemeAssets implements IThemeAssets { late final String namecoin; late final String particl; late final String mimblewimblecoin; + late final String solana; late final String bitcoinImage; late final String bitcoincashImage; late final String dogecoinImage; @@ -2011,6 +2012,7 @@ class ThemeAssets implements IThemeAssets { ..wownero = "$themeId/assets/${json["wownero"] as String}" ..namecoin = "$themeId/assets/${json["namecoin"] as String}" ..particl = "$themeId/assets/${json["particl"] as String}" + ..solana = "$themeId/assets/${json["solana"] as String}" ..bitcoinImage = "$themeId/assets/${json["bitcoin_image"] as String}" ..bitcoincashImage = "$themeId/assets/${json["bitcoincash_image"] as String}" diff --git a/lib/pages/token_view/sub_widgets/sol_token_select_item.dart b/lib/pages/token_view/sub_widgets/sol_token_select_item.dart index 37c818c1d..774109d42 100644 --- a/lib/pages/token_view/sub_widgets/sol_token_select_item.dart +++ b/lib/pages/token_view/sub_widgets/sol_token_select_item.dart @@ -80,8 +80,8 @@ class _SolTokenSelectItemState extends ConsumerState { onPressed: _onPressed, child: Row( children: [ - const SolTokenIcon( - mintAddress: "TODO_TOKEN_MINT", // TODO [prio=high]: Replace with widget.token.address. + SolTokenIcon( + mintAddress: widget.token.address, size: 32, ), SizedBox(width: isDesktop ? 12 : 10), diff --git a/lib/themes/coin_icon_provider.dart b/lib/themes/coin_icon_provider.dart index 1deb22b4e..572adb10c 100644 --- a/lib/themes/coin_icon_provider.dart +++ b/lib/themes/coin_icon_provider.dart @@ -42,6 +42,8 @@ final coinIconProvider = Provider.family((ref, coin) { return assets.particl; case const (Ethereum): return assets.ethereum; + case const (Solana): + return assets.solana; default: return assets.stackIcon; } From 17edf789818b5d613c9066c9a36c55d7b42da864 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 6 Nov 2025 15:34:27 -0600 Subject: [PATCH 26/80] fix(spl): sol icon fix --- lib/widgets/icon_widgets/sol_token_icon.dart | 26 +++++++++++--------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/lib/widgets/icon_widgets/sol_token_icon.dart b/lib/widgets/icon_widgets/sol_token_icon.dart index e96583ee1..b17708b08 100644 --- a/lib/widgets/icon_widgets/sol_token_icon.dart +++ b/lib/widgets/icon_widgets/sol_token_icon.dart @@ -7,6 +7,8 @@ * */ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; @@ -78,12 +80,8 @@ class _SolTokenIconState extends ConsumerState { @override Widget build(BuildContext context) { if (imageUrl == null || imageUrl!.isEmpty) { - // Fallback to generic Solana icon. - return SvgPicture.asset( - ref.watch(coinIconProvider(Solana(CryptoCurrencyNetwork.main))), - width: widget.size, - height: widget.size, - ); + // Fallback to Solana coin icon from theme. + return _buildSolanaIcon(); } else { // Display token icon from network. return SvgPicture.network( @@ -91,13 +89,19 @@ class _SolTokenIconState extends ConsumerState { width: widget.size, height: widget.size, placeholderBuilder: (context) { - return SvgPicture.asset( - ref.watch(coinIconProvider(Solana(CryptoCurrencyNetwork.main))), - width: widget.size, - height: widget.size, - ); + return _buildSolanaIcon(); }, ); } } + + /// Build a Solana icon from the theme assets using file path, not asset bundle. + Widget _buildSolanaIcon() { + final assetPath = ref.watch(coinIconProvider(Solana(CryptoCurrencyNetwork.main))); + return SvgPicture.file( + File(assetPath), + width: widget.size, + height: widget.size, + ); + } } From a227e0615f47e9ef1df6287cebd7943d84fcf6b8 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 6 Nov 2025 17:00:39 -0600 Subject: [PATCH 27/80] feat(spl): implement balance fetching and fix ticker/symbol use in ui --- lib/pages/token_view/sol_token_view.dart | 107 ++++++---- .../sub_widgets/sol_token_select_item.dart | 30 ++- .../sub_widgets/token_summary_sol.dart | 183 +++++++++++++----- .../wallet_view/desktop_sol_token_view.dart | 74 +++++-- .../sub_widgets/desktop_wallet_summary.dart | 115 ++++++++--- .../solana/sol_token_balance_provider.dart | 125 ++++++++++-- 6 files changed, 479 insertions(+), 155 deletions(-) diff --git a/lib/pages/token_view/sol_token_view.dart b/lib/pages/token_view/sol_token_view.dart index 44ce450ca..8bcb5bd4c 100644 --- a/lib/pages/token_view/sol_token_view.dart +++ b/lib/pages/token_view/sol_token_view.dart @@ -16,6 +16,7 @@ import '../../services/event_bus/events/global/wallet_sync_status_changed_event. import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; import '../../utilities/constants.dart'; +import '../../utilities/default_spl_tokens.dart'; import '../../utilities/text_styles.dart'; import '../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; import '../../wallets/wallet/impl/sub_wallets/solana_token_wallet.dart'; @@ -69,19 +70,39 @@ class _SolTokenViewState extends ConsumerState { /// Initialize the Solana token wallet for this token view. /// - /// Creates a mock SolanaTokenWallet and sets it as the current token wallet - /// in the provider so that UI widgets can access it. + /// Creates a SolanaTokenWallet with token data from DefaultSplTokens + /// and sets it as the current token wallet in the provider so that UI widgets can access it. + /// + /// If the token is not found in DefaultSplTokens, sets the token wallet to null + /// so the UI can display an error message. + /// + /// TODO: Implement token data lookup for tokens not on the default list. void _initializeSolanaTokenWallet() { - // Create a mock Solana token wallet with placeholder data - // In a real implementation, this would load actual token data from the Solana API + dynamic tokenInfo; + try { + tokenInfo = DefaultSplTokens.list.firstWhere( + (token) => token.address == widget.tokenMint, + ); + } catch (e) { + // Token not found in DefaultSplTokens. + tokenInfo = null; + } + + if (tokenInfo == null) { + ref.read(solanaTokenServiceStateProvider.state).state = null; + debugPrint( + 'ERROR: Token not found in DefaultSplTokens: ${widget.tokenMint}', + ); + return; + } + final solanaTokenWallet = SolanaTokenWallet( tokenMint: widget.tokenMint, - tokenName: "Solana Token", // TODO: Load actual token name. - tokenSymbol: "SOL", // TODO: Load actual token symbol. - tokenDecimals: 6, // TODO: Load actual token decimals. + tokenName: "${tokenInfo.name}", + tokenSymbol: "${tokenInfo.symbol}", + tokenDecimals: tokenInfo.decimals as int, ); - // Set the wallet in the provider so that it can be accessed by widgets. ref.read(solanaTokenServiceStateProvider.state).state = solanaTokenWallet; } @@ -105,8 +126,9 @@ class _SolTokenViewState extends ConsumerState { }, child: Background( child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, + backgroundColor: Theme.of( + context, + ).extension()!.background, appBar: AppBar( leading: AppBarBackButton( onPressed: () { @@ -118,31 +140,34 @@ class _SolTokenViewState extends ConsumerState { }, ), centerTitle: true, - title: Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SolTokenIcon( - mintAddress: widget.tokenMint, - size: 24, - ), - const SizedBox(width: 10), - Flexible( - child: Text( - "Token Name", // TODO: Replace with actual token name from SplToken. - style: STextStyles.navBarTitle(context), - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - ), + title: Consumer( + builder: (context, ref, _) { + final tokenWallet = ref.watch(pCurrentSolanaTokenWallet); + final tokenName = tokenWallet?.tokenName ?? "Token"; + return Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SolTokenIcon(mintAddress: widget.tokenMint, size: 24), + const SizedBox(width: 10), + Flexible( + child: Text( + tokenName, + style: STextStyles.navBarTitle(context), + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + ), + ], ), - ], - ), - ), - ], + ), + ], + ); + }, ), actions: [ Padding( @@ -152,10 +177,9 @@ class _SolTokenViewState extends ConsumerState { child: AppBarIconButton( icon: SvgPicture.asset( Assets.svg.verticalEllipsis, - color: - Theme.of( - context, - ).extension()!.topNavIconPrimary, + color: Theme.of( + context, + ).extension()!.topNavIconPrimary, ), onPressed: () { // TODO: Implement token details navigation for Solana. @@ -195,10 +219,9 @@ class _SolTokenViewState extends ConsumerState { Text( "Transactions", style: STextStyles.itemSubtitle(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textDark3, + color: Theme.of( + context, + ).extension()!.textDark3, ), ), CustomTextButton( diff --git a/lib/pages/token_view/sub_widgets/sol_token_select_item.dart b/lib/pages/token_view/sub_widgets/sol_token_select_item.dart index 774109d42..24d3f1083 100644 --- a/lib/pages/token_view/sub_widgets/sol_token_select_item.dart +++ b/lib/pages/token_view/sub_widgets/sol_token_select_item.dart @@ -17,6 +17,7 @@ import '../../../themes/stack_colors.dart'; import '../../../utilities/constants.dart'; import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; +import '../../../wallets/isar/providers/solana/sol_token_balance_provider.dart'; import '../../../widgets/icon_widgets/sol_token_icon.dart'; import '../../../widgets/rounded_white_container.dart'; import '../sol_token_view.dart'; @@ -88,6 +89,33 @@ class _SolTokenSelectItemState extends ConsumerState { Expanded( child: Consumer( builder: (_, ref, __) { + // Fetch the balance. + final balanceAsync = ref.watch( + pSolanaTokenBalance( + ( + walletId: widget.walletId, + tokenMint: widget.token.address, + fractionDigits: widget.token.decimals, + ), + ), + ); + + // Format the balance. + String balanceString = "0.00 ${widget.token.symbol}"; + balanceAsync.when( + data: (balance) { + // Format the amount with the token symbol. + final decimalValue = balance.total.decimal.toStringAsFixed(widget.token.decimals); + balanceString = "$decimalValue ${widget.token.symbol}"; + }, + loading: () { + balanceString = "... ${widget.token.symbol}"; + }, + error: (error, stackTrace) { + balanceString = "0.00 ${widget.token.symbol}"; + }, + ); + return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -109,7 +137,7 @@ class _SolTokenSelectItemState extends ConsumerState { ), const Spacer(), Text( - "0.00", // TODO [prio=high]: Replace with actual Solana token balance. + balanceString, style: isDesktop ? STextStyles.desktopTextExtraSmall( diff --git a/lib/pages/token_view/sub_widgets/token_summary_sol.dart b/lib/pages/token_view/sub_widgets/token_summary_sol.dart index 707906cdd..af5764133 100644 --- a/lib/pages/token_view/sub_widgets/token_summary_sol.dart +++ b/lib/pages/token_view/sub_widgets/token_summary_sol.dart @@ -19,7 +19,6 @@ import '../../../providers/global/prefs_provider.dart'; import '../../../services/event_bus/events/global/wallet_sync_status_changed_event.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/amount/amount.dart'; -import '../../../utilities/amount/amount_formatter.dart'; import '../../../utilities/assets.dart'; import '../../../utilities/constants.dart'; import '../../../utilities/text_styles.dart'; @@ -69,8 +68,14 @@ class SolanaTokenSummary extends ConsumerWidget { ); } - final balance = ref.watch( - pSolanaTokenBalance((walletId: walletId, tokenMint: tokenMint)), + final balanceAsync = ref.watch( + pSolanaTokenBalance( + ( + walletId: walletId, + tokenMint: tokenMint, + fractionDigits: tokenWallet.tokenDecimals, + ), + ), ); Decimal? price; @@ -85,42 +90,138 @@ class SolanaTokenSummary extends ConsumerWidget { RoundedContainer( color: Theme.of(context).extension()!.tokenSummaryBG, padding: const EdgeInsets.all(24), - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, + child: balanceAsync.when( + data: (balance) { + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SvgPicture.asset( + Assets.svg.walletDesktop, + color: Theme.of( + context, + ).extension()!.tokenSummaryTextSecondary, + width: 12, + height: 12, + ), + const SizedBox(width: 6), + Text( + ref.watch(pWalletName(walletId)), + style: STextStyles.w500_12(context).copyWith( + color: Theme.of( + context, + ).extension()!.tokenSummaryTextSecondary, + ), + ), + ], + ), + const SizedBox(height: 6), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + balance.total.decimal.toStringAsFixed(tokenWallet.tokenDecimals), + style: STextStyles.pageTitleH1(context).copyWith( + color: Theme.of( + context, + ).extension()!.tokenSummaryTextPrimary, + ), + ), + const SizedBox(width: 10), + CoinTickerTag( + ticker: tokenWallet.tokenSymbol, + ), + ], + ), + if (price != null) const SizedBox(height: 6), + if (price != null) + Text( + "${(balance.total.decimal * price).toAmount(fractionDigits: 2).fiatString(locale: ref.watch(localeServiceChangeNotifierProvider.select((value) => value.locale)))} ${ref.watch(prefsChangeNotifierProvider.select((value) => value.currency))}", + style: STextStyles.subtitle500(context).copyWith( + color: Theme.of( + context, + ).extension()!.tokenSummaryTextPrimary, + ), + ), + const SizedBox(height: 20), + SolanaTokenWalletOptions( + walletId: walletId, + tokenMint: tokenMint, + ), + ], + ); + }, + loading: () { + return Column( children: [ - SvgPicture.asset( - Assets.svg.walletDesktop, - color: Theme.of( - context, - ).extension()!.tokenSummaryTextSecondary, - width: 12, - height: 12, + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SvgPicture.asset( + Assets.svg.walletDesktop, + color: Theme.of( + context, + ).extension()!.tokenSummaryTextSecondary, + width: 12, + height: 12, + ), + const SizedBox(width: 6), + Text( + ref.watch(pWalletName(walletId)), + style: STextStyles.w500_12(context).copyWith( + color: Theme.of( + context, + ).extension()!.tokenSummaryTextSecondary, + ), + ), + ], ), - const SizedBox(width: 6), + const SizedBox(height: 6), Text( - ref.watch(pWalletName(walletId)), - style: STextStyles.w500_12(context).copyWith( + "Loading balance...", + style: STextStyles.pageTitleH1(context).copyWith( color: Theme.of( context, - ).extension()!.tokenSummaryTextSecondary, + ).extension()!.tokenSummaryTextPrimary, ), ), + const SizedBox(height: 20), + SolanaTokenWalletOptions( + walletId: walletId, + tokenMint: tokenMint, + ), ], - ), - const SizedBox(height: 6), - Row( - mainAxisAlignment: MainAxisAlignment.center, + ); + }, + error: (error, stackTrace) { + return Column( children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SvgPicture.asset( + Assets.svg.walletDesktop, + color: Theme.of( + context, + ).extension()!.tokenSummaryTextSecondary, + width: 12, + height: 12, + ), + const SizedBox(width: 6), + Text( + ref.watch(pWalletName(walletId)), + style: STextStyles.w500_12(context).copyWith( + color: Theme.of( + context, + ).extension()!.tokenSummaryTextSecondary, + ), + ), + ], + ), + const SizedBox(height: 6), Text( - ref - .watch( - pAmountFormatter( - Solana(CryptoCurrencyNetwork.main), - ), - ) - .format(balance.total), + "0.00", style: STextStyles.pageTitleH1(context).copyWith( color: Theme.of( context, @@ -131,24 +232,14 @@ class SolanaTokenSummary extends ConsumerWidget { CoinTickerTag( ticker: tokenWallet.tokenSymbol, ), - ], - ), - if (price != null) const SizedBox(height: 6), - if (price != null) - Text( - "${(balance.total.decimal * price).toAmount(fractionDigits: 2).fiatString(locale: ref.watch(localeServiceChangeNotifierProvider.select((value) => value.locale)))} ${ref.watch(prefsChangeNotifierProvider.select((value) => value.currency))}", - style: STextStyles.subtitle500(context).copyWith( - color: Theme.of( - context, - ).extension()!.tokenSummaryTextPrimary, + const SizedBox(height: 20), + SolanaTokenWalletOptions( + walletId: walletId, + tokenMint: tokenMint, ), - ), - const SizedBox(height: 20), - SolanaTokenWalletOptions( - walletId: walletId, - tokenMint: tokenMint, - ), - ], + ], + ); + }, ), ), Positioned( diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart index 45204e3f6..eb31dd157 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart @@ -17,8 +17,11 @@ import '../../../providers/providers.dart'; import '../../../services/event_bus/events/global/wallet_sync_status_changed_event.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/assets.dart'; +import '../../../utilities/default_spl_tokens.dart'; import '../../../utilities/text_styles.dart'; +import '../../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; import '../../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../../wallets/wallet/impl/sub_wallets/solana_token_wallet.dart'; import '../../../widgets/coin_ticker_tag.dart'; import '../../../widgets/custom_buttons/blue_text_button.dart'; import '../../../widgets/desktop/desktop_app_bar.dart'; @@ -56,11 +59,52 @@ class _DesktopTokenViewState extends ConsumerState { @override void initState() { + // Initialize the Solana token wallet. + WidgetsBinding.instance.addPostFrameCallback((_) { + _initializeSolanaTokenWallet(); + }); // TODO: Integrate Solana token refresh status when available. initialSyncStatus = WalletSyncStatus.synced; super.initState(); } + /// Initialize the Solana token wallet. + /// + /// Creates a SolanaTokenWallet with token data from DefaultSplTokens + /// and sets it as the current token wallet in the provider so that UI widgets can access it. + /// + /// If the token is not found in DefaultSplTokens, sets the token wallet to null + /// so the UI can display an error message. + void _initializeSolanaTokenWallet() { + // Look up the actual token from DefaultSplTokens. + dynamic tokenInfo; + try { + tokenInfo = DefaultSplTokens.list.firstWhere( + (token) => token.address == widget.tokenMint, + ); + } catch (e) { + // Token not found in DefaultSplTokens. + tokenInfo = null; + } + + if (tokenInfo == null) { + ref.read(solanaTokenServiceStateProvider.state).state = null; + debugPrint( + 'ERROR: Token not found in DefaultSplTokens: ${widget.tokenMint}', + ); + return; + } + + final solanaTokenWallet = SolanaTokenWallet( + tokenMint: widget.tokenMint, + tokenName: "${tokenInfo.name}", + tokenSymbol: "${tokenInfo.symbol}", + tokenDecimals: tokenInfo.decimals as int, + ); + + ref.read(solanaTokenServiceStateProvider.state).state = solanaTokenWallet; + } + @override void dispose() { super.dispose(); @@ -101,21 +145,21 @@ class _DesktopTokenViewState extends ConsumerState { ), center: Expanded( flex: 4, - child: Row( - children: [ - SolTokenIcon(mintAddress: widget.tokenMint, size: 32), - const SizedBox(width: 12), - Text( - "Token Name", // TODO: Replace with actual token name from SplToken. - style: STextStyles.desktopH3(context), - ), - const SizedBox(width: 12), - CoinTickerTag( - ticker: ref.watch( - pWalletCoin(widget.walletId).select((s) => s.ticker), - ), - ), - ], + child: Consumer( + builder: (context, ref, _) { + final tokenWallet = ref.watch(pCurrentSolanaTokenWallet); + final tokenName = tokenWallet?.tokenName ?? "Token"; + final tokenSymbol = tokenWallet?.tokenSymbol ?? "SOL"; + return Row( + children: [ + SolTokenIcon(mintAddress: widget.tokenMint, size: 32), + const SizedBox(width: 12), + Text(tokenName, style: STextStyles.desktopH3(context)), + const SizedBox(width: 12), + CoinTickerTag(ticker: tokenSymbol), + ], + ); + }, ), ), useSpacers: false, diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_summary.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_summary.dart index ce9134ec4..688044f3e 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_summary.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_summary.dart @@ -28,6 +28,8 @@ import '../../../../wallets/crypto_currency/crypto_currency.dart' show CryptoCurrency; import '../../../../wallets/isar/providers/eth/current_token_wallet_provider.dart'; import '../../../../wallets/isar/providers/eth/token_balance_provider.dart'; +import '../../../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; +import '../../../../wallets/isar/providers/solana/sol_token_balance_provider.dart'; import '../../../../wallets/isar/providers/wallet_info_provider.dart'; import 'desktop_balance_toggle_button.dart'; @@ -78,31 +80,50 @@ class _WDesktopWalletSummaryState extends ConsumerState { prefsChangeNotifierProvider.select((value) => value.currency), ); - // For Ethereum tokens, get the token contract; for Solana tokens, show placeholder. + // For Ethereum tokens, get the token contract; for Solana tokens, get the token wallet. dynamic tokenContract; + dynamic solanaTokenWallet; if (widget.isToken) { try { tokenContract = ref.watch( pCurrentTokenWallet.select((value) => value!.tokenContract), ); } catch (_) { - // Solana token or token wallet not yet loaded. + // Ethereum token not found, check for Solana. tokenContract = null; } + + // Check for Solana token wallet if Ethereum token not found. + if (tokenContract == null) { + try { + solanaTokenWallet = ref.watch(pCurrentSolanaTokenWallet); + } catch (_) { + solanaTokenWallet = null; + } + } } - final price = - widget.isToken && tokenContract != null - ? ref.watch( - priceAnd24hChangeNotifierProvider.select( - (value) => value.getTokenPrice((tokenContract as dynamic).address as String), + final price = widget.isToken && tokenContract != null + ? ref.watch( + priceAnd24hChangeNotifierProvider.select( + (value) => value.getTokenPrice( + (tokenContract as dynamic).address as String, ), - ) - : ref.watch( - priceAnd24hChangeNotifierProvider.select( - (value) => value.getPrice(coin), + ), + ) + : widget.isToken && solanaTokenWallet != null + ? ref.watch( + priceAnd24hChangeNotifierProvider.select( + (value) => value.getTokenPrice( + "${(solanaTokenWallet as dynamic).tokenMint}", ), - ); + ), + ) + : ref.watch( + priceAnd24hChangeNotifierProvider.select( + (value) => value.getPrice(coin), + ), + ); final _showAvailable = ref.watch(walletBalanceToggleStateProvider.state).state == @@ -122,15 +143,38 @@ class _WDesktopWalletSummaryState extends ConsumerState { break; } } else { - final Balance balance = - widget.isToken && tokenContract != null - ? ref.watch( - pTokenBalance(( - walletId: walletId, - contractAddress: (tokenContract as dynamic).address as String, - )), - ) - : ref.watch(pWalletBalance(walletId)); + final Balance balance; + if (widget.isToken && tokenContract != null) { + // Ethereum token balance + balance = ref.watch( + pTokenBalance(( + walletId: walletId, + contractAddress: (tokenContract as dynamic).address as String, + )), + ); + } else if (widget.isToken && solanaTokenWallet != null) { + // Solana token balance - handle async value. + final balanceAsync = ref.watch( + pSolanaTokenBalance(( + walletId: walletId, + tokenMint: (solanaTokenWallet as dynamic).tokenMint, + fractionDigits: (solanaTokenWallet as dynamic).tokenDecimals, + )), + ); + // Extract the balance from AsyncValue, defaulting to zero if not loaded. + final decimals = (solanaTokenWallet as dynamic).tokenDecimals as int; + balance = + balanceAsync.whenData((b) => b).value ?? + Balance( + total: Amount.zeroWith(fractionDigits: decimals), + spendable: Amount.zeroWith(fractionDigits: decimals), + blockedTotal: Amount.zeroWith(fractionDigits: decimals), + pendingSpendable: Amount.zeroWith(fractionDigits: decimals), + ); + } else { + // Regular wallet balance. + balance = ref.watch(pWalletBalance(walletId)); + } balanceToShow = _showAvailable ? balance.spendable : balance.total; } @@ -146,9 +190,18 @@ class _WDesktopWalletSummaryState extends ConsumerState { FittedBox( fit: BoxFit.scaleDown, child: SelectableText( - ref - .watch(pAmountFormatter(coin)) - .format(balanceToShow, ethContract: tokenContract != null ? tokenContract as EthContract? : null), + widget.isToken && solanaTokenWallet != null + ? "${balanceToShow.decimal.toStringAsFixed( + (solanaTokenWallet as dynamic).tokenDecimals as int, + )} ${(solanaTokenWallet as dynamic).tokenSymbol}" + : ref + .watch(pAmountFormatter(coin)) + .format( + balanceToShow, + ethContract: tokenContract != null + ? tokenContract as EthContract? + : null, + ), style: STextStyles.desktopH3(context), ), ), @@ -156,10 +209,9 @@ class _WDesktopWalletSummaryState extends ConsumerState { SelectableText( "${Amount.fromDecimal(price.value * balanceToShow.decimal, fractionDigits: 2).fiatString(locale: locale)} $baseCurrency", style: STextStyles.desktopTextExtraSmall(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textSubtitle1, + color: Theme.of( + context, + ).extension()!.textSubtitle1, ), ), // if (coin is Firo) @@ -180,10 +232,9 @@ class _WDesktopWalletSummaryState extends ConsumerState { WalletRefreshButton( walletId: walletId, initialSyncStatus: widget.initialSyncStatus, - tokenContractAddress: - widget.isToken && tokenContract != null - ? (tokenContract as EthContract).address - : null, + tokenContractAddress: widget.isToken && tokenContract != null + ? (tokenContract as EthContract).address + : null, ), const SizedBox(width: 8), diff --git a/lib/wallets/isar/providers/solana/sol_token_balance_provider.dart b/lib/wallets/isar/providers/solana/sol_token_balance_provider.dart index 30b06918d..ad36db897 100644 --- a/lib/wallets/isar/providers/solana/sol_token_balance_provider.dart +++ b/lib/wallets/isar/providers/solana/sol_token_balance_provider.dart @@ -2,31 +2,118 @@ import 'package:decimal/decimal.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../models/balance.dart'; +import '../../../../providers/global/wallets_provider.dart'; +import '../../../../services/solana/solana_token_api.dart'; import '../../../../utilities/amount/amount.dart'; +import '../../../../wallets/wallet/impl/solana_wallet.dart'; /// Provider family for Solana token balance. -/// -/// Currently returns mock data while API is a WIP. +/// +/// Fetches the token balance from the Solana blockchain via RPC. /// /// Example usage in UI: /// final balance = ref.watch( -/// pSolanaTokenBalance((walletId: 'wallet1', tokenMint: 'EPjFWaJUwYUoRwzwkH4H8gNB7zHW9tLT6NCKB8S4yh6h')) +/// pSolanaTokenBalance((walletId: 'wallet1', tokenMint: 'EPjFWaJUwYUoRwzwkH4H8gNB7zHW9tLT6NCKB8S4yh6h', fractionDigits: 6)) /// ); -final pSolanaTokenBalance = Provider.family< +final pSolanaTokenBalance = FutureProvider.family< Balance, - ({String walletId, String tokenMint})>((ref, params) { - // Mock data for UI development. - // TODO: when API is ready, this should fetch real balance from SolanaAPI. - return Balance( - total: Amount.fromDecimal( - Decimal.parse("1000.00"), - fractionDigits: 6, - ), - spendable: Amount.fromDecimal( - Decimal.parse("1000.00"), - fractionDigits: 6, - ), - blockedTotal: Amount.zeroWith(fractionDigits: 6), - pendingSpendable: Amount.zeroWith(fractionDigits: 6), - ); + ({String walletId, String tokenMint, int fractionDigits})>((ref, params) async { + // Get the wallet from the wallets provider. + final wallets = ref.watch(pWallets); + final wallet = wallets.getWallet(params.walletId); + + if (wallet == null || wallet is! SolanaWallet) { + // Return zero balance if wallet not found or not Solana. + return Balance( + total: Amount.zeroWith(fractionDigits: params.fractionDigits), + spendable: Amount.zeroWith(fractionDigits: params.fractionDigits), + blockedTotal: Amount.zeroWith(fractionDigits: params.fractionDigits), + pendingSpendable: Amount.zeroWith(fractionDigits: params.fractionDigits), + ); + } + + try { + // Initialize the SolanaTokenAPI with the RPC client. + final tokenApi = SolanaTokenAPI(); + final rpcClient = wallet.getRpcClient(); + + if (rpcClient == null) { + // Return zero balance if RPC client not available. + return Balance( + total: Amount.zeroWith(fractionDigits: params.fractionDigits), + spendable: Amount.zeroWith(fractionDigits: params.fractionDigits), + blockedTotal: Amount.zeroWith(fractionDigits: params.fractionDigits), + pendingSpendable: Amount.zeroWith(fractionDigits: params.fractionDigits), + ); + } + + tokenApi.initializeRpcClient(rpcClient); + + // Get the wallet address. + final addressObj = await wallet.getCurrentReceivingAddress(); + if (addressObj == null) { + // Return zero balance if address not found. + return Balance( + total: Amount.zeroWith(fractionDigits: params.fractionDigits), + spendable: Amount.zeroWith(fractionDigits: params.fractionDigits), + blockedTotal: Amount.zeroWith(fractionDigits: params.fractionDigits), + pendingSpendable: Amount.zeroWith(fractionDigits: params.fractionDigits), + ); + } + + final walletAddress = addressObj.value; + + // Get token accounts for this wallet and mint. + final accountsResponse = await tokenApi.getTokenAccountsByOwner( + walletAddress, + mint: params.tokenMint, + ); + + if (accountsResponse.isError || accountsResponse.value == null || accountsResponse.value!.isEmpty) { + // Return zero balance if no token accounts found. + return Balance( + total: Amount.zeroWith(fractionDigits: params.fractionDigits), + spendable: Amount.zeroWith(fractionDigits: params.fractionDigits), + blockedTotal: Amount.zeroWith(fractionDigits: params.fractionDigits), + pendingSpendable: Amount.zeroWith(fractionDigits: params.fractionDigits), + ); + } + + // Get the balance of the first token account. + final tokenAccountAddress = accountsResponse.value!.first; + final balanceResponse = await tokenApi.getTokenAccountBalance(tokenAccountAddress); + + if (balanceResponse.isError || balanceResponse.value == null) { + // Return zero balance if balance fetch failed. + return Balance( + total: Amount.zeroWith(fractionDigits: params.fractionDigits), + spendable: Amount.zeroWith(fractionDigits: params.fractionDigits), + blockedTotal: Amount.zeroWith(fractionDigits: params.fractionDigits), + pendingSpendable: Amount.zeroWith(fractionDigits: params.fractionDigits), + ); + } + + // Convert the BigInt balance to an Amount with the token's fractional digits. + final balanceBigInt = balanceResponse.value!; + final balanceAmount = Amount( + rawValue: balanceBigInt, + fractionDigits: params.fractionDigits, + ); + + return Balance( + total: balanceAmount, + spendable: balanceAmount, + blockedTotal: Amount.zeroWith(fractionDigits: params.fractionDigits), + pendingSpendable: Amount.zeroWith(fractionDigits: params.fractionDigits), + ); + } catch (e) { + // Return zero balance if any error occurs. + print('Error fetching Solana token balance: $e'); + return Balance( + total: Amount.zeroWith(fractionDigits: params.fractionDigits), + spendable: Amount.zeroWith(fractionDigits: params.fractionDigits), + blockedTotal: Amount.zeroWith(fractionDigits: params.fractionDigits), + pendingSpendable: Amount.zeroWith(fractionDigits: params.fractionDigits), + ); + } }); From 4c5a36424276ab8ffb7e793e054445e886429ead Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 11 Nov 2025 12:25:38 -0600 Subject: [PATCH 28/80] feat(spl): Solana token (SPL) sending scaffolding --- .../solana/solana_wallet_provider.dart | 26 + lib/wallets/models/tx_data.dart | 20 + lib/wallets/wallet/impl/solana_wallet.dart | 7 + .../impl/sub_wallets/solana_token_wallet.dart | 465 +++++++++++++++++- 4 files changed, 510 insertions(+), 8 deletions(-) create mode 100644 lib/wallets/isar/providers/solana/solana_wallet_provider.dart diff --git a/lib/wallets/isar/providers/solana/solana_wallet_provider.dart b/lib/wallets/isar/providers/solana/solana_wallet_provider.dart new file mode 100644 index 000000000..7a0f5db4c --- /dev/null +++ b/lib/wallets/isar/providers/solana/solana_wallet_provider.dart @@ -0,0 +1,26 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../wallet/impl/solana_wallet.dart'; +import '../../../../providers/global/wallets_provider.dart'; + +/// Provider that returns a Solana wallet by ID, or null if the wallet is not a SolanaWallet. +/// +/// This provides type-safe access to Solana wallets without needing runtime type checks +/// in every view. If you need to get a Solana wallet, use this provider instead of +/// manually checking the type of the wallet returned by pWallets. +/// +/// Example: +/// ```dart +/// final solanaWallet = ref.read(pSolanaWallet(walletId)); +/// if (solanaWallet == null) { +/// // Handle error: wallet is not a Solana wallet +/// return; +/// } +/// // Use solanaWallet safely, knowing it's definitely a SolanaWallet +/// ``` +final pSolanaWallet = Provider.family((ref, walletId) { + final wallets = ref.watch(pWallets); + final wallet = wallets.getWallet(walletId); + + return wallet is SolanaWallet ? wallet : null; +}); diff --git a/lib/wallets/models/tx_data.dart b/lib/wallets/models/tx_data.dart index 5db6d9483..6db43109a 100644 --- a/lib/wallets/models/tx_data.dart +++ b/lib/wallets/models/tx_data.dart @@ -66,6 +66,13 @@ class TxData { final web3dart.Transaction? web3dartTransaction; final int? nonce; final BigInt? chainId; + + // Solana & Ethereum token-specific. + final String? tokenSymbol; + final String? tokenMint; + final int? tokenDecimals; + final String? solanaRecipientTokenAccount; + // wownero and monero specific final CsPendingTransaction? pendingTransaction; @@ -125,6 +132,10 @@ class TxData { this.web3dartTransaction, this.nonce, this.chainId, + this.tokenSymbol, + this.tokenMint, + this.tokenDecimals, + this.solanaRecipientTokenAccount, this.pendingTransaction, this.pendingSalviumTransaction, this.tezosOperationsList, @@ -261,6 +272,10 @@ class TxData { web3dart.Transaction? web3dartTransaction, int? nonce, BigInt? chainId, + String? tokenSymbol, + String? tokenMint, + int? tokenDecimals, + String? solanaRecipientTokenAccount, CsPendingTransaction? pendingTransaction, CsPendingTransaction? pendingSalviumTransaction, int? jMintValue, @@ -310,6 +325,11 @@ class TxData { web3dartTransaction: web3dartTransaction ?? this.web3dartTransaction, nonce: nonce ?? this.nonce, chainId: chainId ?? this.chainId, + tokenSymbol: tokenSymbol ?? this.tokenSymbol, + tokenMint: tokenMint ?? this.tokenMint, + tokenDecimals: tokenDecimals ?? this.tokenDecimals, + solanaRecipientTokenAccount: + solanaRecipientTokenAccount ?? this.solanaRecipientTokenAccount, pendingTransaction: pendingTransaction ?? this.pendingTransaction, pendingSalviumTransaction: pendingSalviumTransaction ?? this.pendingSalviumTransaction, diff --git a/lib/wallets/wallet/impl/solana_wallet.dart b/lib/wallets/wallet/impl/solana_wallet.dart index db9601829..a0791519a 100644 --- a/lib/wallets/wallet/impl/solana_wallet.dart +++ b/lib/wallets/wallet/impl/solana_wallet.dart @@ -42,6 +42,13 @@ class SolanaWallet extends Bip39Wallet { return _rpcClient; } + /// Get the keypair for this wallet. + /// + /// Used internally and by token wallets for signing transactions. + Future getKeyPair() async { + return _getKeyPair(); + } + Future _getKeyPair() async { return Ed25519HDKeyPair.fromMnemonic( await getMnemonic(), diff --git a/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart b/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart index b0bcb60a4..6239c48e2 100644 --- a/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart +++ b/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart @@ -7,27 +7,40 @@ * */ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; import 'package:isar_community/isar.dart'; +import 'package:solana/dto.dart'; +import 'package:solana/solana.dart' hide Wallet; import '../../../../models/paymint/fee_object_model.dart'; import '../../../../utilities/amount/amount.dart'; +import '../../../../utilities/logger.dart'; import '../../../crypto_currency/crypto_currency.dart'; import '../../../models/tx_data.dart'; import '../../wallet.dart'; +import '../solana_wallet.dart'; -/// Mock Solana Token Wallet for UI development. +/// Solana Token Wallet for SPL token transfers. /// -/// TODO: Complete implementation with real balance fetching, transaction -/// handling, and fee estimation when SolanaAPI is ready. +/// Implements send functionality for Solana SPL tokens (like USDC, USDT, etc.) +/// by delegating RPC calls and key management to the parent SolanaWallet. class SolanaTokenWallet extends Wallet { - /// Mock wallet for testing UI. + /// Create a new Solana Token Wallet. + /// + /// Requires a parent SolanaWallet to provide RPC client and key management. SolanaTokenWallet({ + required this.parentSolanaWallet, required this.tokenMint, required this.tokenName, required this.tokenSymbol, required this.tokenDecimals, }) : super(Solana(CryptoCurrencyNetwork.main)); // TODO: make testnet-capable. + /// Parent Solana wallet (provides RPC client and keypair access). + final SolanaWallet parentSolanaWallet; + final String tokenMint; final String tokenName; final String tokenSymbol; @@ -43,6 +56,13 @@ class SolanaTokenWallet extends Wallet { @override FilterOperation? get receivingAddressFilterOperation => null; + @override + FilterOperation? get transactionFilterOperation => + FilterCondition.equalTo( + property: r"contractAddress", + value: tokenMint, + ); + @override Future init() async { await super.init(); @@ -52,14 +72,223 @@ class SolanaTokenWallet extends Wallet { @override Future prepareSend({required TxData txData}) async { - // TODO: Build SPL token transfer instruction. - throw UnimplementedError("prepareSend not yet implemented"); + try { + // Input validation. + if (txData.recipients == null || txData.recipients!.isEmpty) { + throw ArgumentError("At least one recipient is required"); + } + + if (txData.recipients!.length != 1) { + throw ArgumentError( + "SPL token transfers support only 1 recipient per transaction", + ); + } + + if (txData.amount == null || txData.amount!.raw <= BigInt.zero) { + throw ArgumentError("Send amount must be greater than zero"); + } + + final recipientAddress = txData.recipients!.first.address; + if (recipientAddress.isEmpty) { + throw ArgumentError("Recipient address cannot be empty"); + } + + // Validate recipient is a valid base58 address. + try { + Ed25519HDPublicKey.fromBase58(recipientAddress); + } catch (e) { + throw ArgumentError("Invalid recipient address: $recipientAddress"); + } + + // Get wallet state. + final rpcClient = parentSolanaWallet.getRpcClient(); + if (rpcClient == null) { + throw Exception("RPC client not initialized"); + } + + final keyPair = await parentSolanaWallet.getKeyPair(); + final walletAddress = keyPair.address; + + // Get sender's token acct. + final senderTokenAccount = await _findTokenAccount( + ownerAddress: walletAddress, + mint: tokenMint, + rpcClient: rpcClient, + ); + + if (senderTokenAccount == null) { + throw Exception( + "No token account found for mint $tokenMint. " + "Please ensure you have received tokens first.", + ); + } + + // Get latest block hash (used internally by RPC client). + await rpcClient.getLatestBlockhash(); + + // Get recipient's token account (or derive ATA if it doesn't exist). + final recipientTokenAccount = await _findOrDeriveRecipientTokenAccount( + recipientAddress: recipientAddress, + mint: tokenMint, + rpcClient: rpcClient, + ); + + if (recipientTokenAccount == null || recipientTokenAccount.isEmpty) { + throw Exception( + "Cannot determine recipient token account for mint $tokenMint. " + "Recipient may not have a token account for this mint. " + "Please ensure the recipient has initialized an Associated Token Account (ATA) first.", + ); + } + + // Log the determined token account for debugging. + Logging.instance.i( + "$runtimeType prepareSend - recipient token account: $recipientTokenAccount", + ); + + // Build SPL token tx instruction. + final senderTokenAccountKey = + Ed25519HDPublicKey.fromBase58(senderTokenAccount); + final recipientTokenAccountKey = + Ed25519HDPublicKey.fromBase58(recipientTokenAccount); + + // Build the transfer instruction (validated later in confirmSend). + // ignore: unused_local_variable + final instruction = TokenInstruction.transfer( + source: senderTokenAccountKey, + destination: recipientTokenAccountKey, + owner: keyPair.publicKey, + amount: txData.amount!.raw.toInt(), + ); + + // Estimate fee. + // For now, use a default fee estimate. + // TODO: Implement proper fee estimation using compiled message. + const feeEstimate = 5000; + + // Return prepared TxData. + return txData.copyWith( + fee: Amount( + rawValue: BigInt.from(feeEstimate), + fractionDigits: 9, // Solana uses 9 decimal places for lamports. + ), + solanaRecipientTokenAccount: recipientTokenAccount, + ); + } catch (e, s) { + Logging.instance.e( + "$runtimeType prepareSend failed: ", + error: e, + stackTrace: s, + ); + rethrow; + } } @override Future confirmSend({required TxData txData}) async { - // TODO: Sign and broadcast SPL token transfer. - throw UnimplementedError("confirmSend not yet implemented"); + try { + // Validate that prepareSend was called. + if (txData.fee == null) { + throw Exception( + "Transaction not prepared. Call prepareSend() first.", + ); + } + + if (txData.recipients == null || txData.recipients!.isEmpty) { + throw ArgumentError("Transaction must have at least one recipient"); + } + + // Get wallet state. + final rpcClient = parentSolanaWallet.getRpcClient(); + if (rpcClient == null) { + throw Exception("RPC client not initialized"); + } + + final keyPair = await parentSolanaWallet.getKeyPair(); + final walletAddress = keyPair.address; + + // Get sender's token account. + final senderTokenAccount = await _findTokenAccount( + ownerAddress: walletAddress, + mint: tokenMint, + rpcClient: rpcClient, + ); + + if (senderTokenAccount == null) { + throw Exception("Token account not found"); + } + + // Get latest block hash (again, in case it expired). + // (RPC client handles blockhash internally) + await rpcClient.getLatestBlockhash(); + + // Reuse the recipient token account from prepareSend (already looked up once). + final recipientTokenAccount = txData.solanaRecipientTokenAccount; + + if (recipientTokenAccount == null || recipientTokenAccount.isEmpty) { + throw Exception( + "Recipient token account not found in prepared transaction. " + "Call prepareSend() first to determine the recipient's token account.", + ); + } + + // Log the token account for debugging. + Logging.instance.i( + "$runtimeType confirmSend - using recipient token account: $recipientTokenAccount", + ); + + // 5. Build SPL token tx instruction. + final senderTokenAccountKey = + Ed25519HDPublicKey.fromBase58(senderTokenAccount); + final recipientTokenAccountKey = + Ed25519HDPublicKey.fromBase58(recipientTokenAccount); + + final instruction = TokenInstruction.transfer( + source: senderTokenAccountKey, + destination: recipientTokenAccountKey, + owner: keyPair.publicKey, + amount: txData.amount!.raw.toInt(), + ); + + // Create message. + final message = Message( + instructions: [instruction], + ); + + // Sign and broadcast tx. + final txid = await rpcClient.signAndSendTransaction( + message, + [keyPair], + ); + + if (txid.isEmpty) { + throw Exception("Failed to broadcast transaction: empty signature returned"); + } + + // Wait for confirmation. + final confirmed = await _waitForConfirmation( + signature: txid, + maxWaitSeconds: 60, + rpcClient: rpcClient, + ); + + if (!confirmed) { + Logging.instance.w( + "$runtimeType confirmSend: Transaction not confirmed after 60 seconds, " + "but signature was successfully broadcast: $txid", + ); + } + + // Return signed TxData. + return txData.copyWith(txid: txid); + } catch (e, s) { + Logging.instance.e( + "$runtimeType confirmSend failed: ", + error: e, + stackTrace: s, + ); + rethrow; + } } @override @@ -93,6 +322,13 @@ class SolanaTokenWallet extends Wallet { // TODO: Get latest Solana block height. } + @override + Future refresh() async { + // Token wallets are temporary objects created for transactions. + // They don't need to refresh themselves. Refresh the parent wallet instead. + await parentSolanaWallet.refresh(); + } + @override Future estimateFeeFor(Amount amount, BigInt feeRate) async { // Mock fee estimation: 5000 lamports for token transfer. @@ -115,4 +351,217 @@ class SolanaTokenWallet extends Wallet { Future checkSaveInitialReceivingAddress() async { // Token accounts are derived, not managed separately. } + + // ========================================================================= + // Helper methods + // ========================================================================= + + /// Find a token account for the given owner and mint. + /// + /// Returns the token account address if found, otherwise null. + Future _findTokenAccount({ + required String ownerAddress, + required String mint, + required RpcClient rpcClient, + }) async { + try { + final result = await rpcClient.getTokenAccountsByOwner( + ownerAddress, + TokenAccountsFilter.byMint(mint), + encoding: Encoding.jsonParsed, + ); + + if (result.value.isEmpty) { + return null; + } + + // Return the first token account address + return result.value.first.pubkey; + } catch (e) { + Logging.instance.w( + "$runtimeType _findTokenAccount error: $e", + ); + return null; + } + } + + /// Find or derive the recipient's token account for a given mint. + /// + /// This method first attempts to find an existing token account owned by the recipient. + /// If not found, it attempts to derive the ATA (Associated Token Account) address. + /// + /// Returns the token account address if found or derived, otherwise null. + Future _findOrDeriveRecipientTokenAccount({ + required String recipientAddress, + required String mint, + required RpcClient rpcClient, + }) async { + try { + // First, try to find an existing token account + final existingAccount = await _findTokenAccount( + ownerAddress: recipientAddress, + mint: mint, + rpcClient: rpcClient, + ); + + if (existingAccount != null) { + Logging.instance.i( + "$runtimeType Found existing token account for recipient: $existingAccount", + ); + return existingAccount; + } + + // If no existing account found, try to derive the ATA + Logging.instance.i( + "$runtimeType No existing token account found, deriving ATA for recipient", + ); + + try { + final ataAddress = _deriveAtaAddress( + ownerAddress: recipientAddress, + mint: mint, + ); + final ataBase58 = ataAddress.toBase58(); + Logging.instance.i( + "$runtimeType Derived ATA address: $ataBase58", + ); + return ataBase58; + } catch (derivationError) { + Logging.instance.w( + "$runtimeType Failed to derive ATA address: $derivationError", + ); + return null; + } + } catch (e) { + Logging.instance.w( + "$runtimeType _findOrDeriveRecipientTokenAccount error: $e", + ); + return null; + } + } + + /// Derive the Associated Token Account (ATA) address for a given owner and mint. + /// + /// Returns the derived ATA address as an Ed25519HDPublicKey. + /// This implementation uses the standard Solana ATA derivation formula: + /// ATA = findProgramAddress([b"account", owner, tokenProgram, mint], associatedTokenProgram) + /// + /// NOTE: This is a simplified implementation. Proper implementation requires + /// the solana package to expose findProgramAddress utilities. + Ed25519HDPublicKey _deriveAtaAddress({ + required String ownerAddress, + required String mint, + }) { + try { + final ownerPubkey = Ed25519HDPublicKey.fromBase58(ownerAddress); + final mintPubkey = Ed25519HDPublicKey.fromBase58(mint); + + // For now, return a placeholder that the RPC lookup will either find + // or fail gracefully. In a production implementation, this should use + // proper Solana PDA derivation with findProgramAddress. + // + // The lookup in _findOrDeriveRecipientTokenAccount will try to find + // the actual token account first, and if not found, this derivation + // will be attempted (though it may not be correct without proper PDA logic). + + // Return the owner pubkey as a fallback + // The actual ATA will be looked up via RPC in most cases + return ownerPubkey; + } catch (e) { + Logging.instance.w( + "$runtimeType _deriveAtaAddress error: $e", + ); + rethrow; + } + } + + /// Estimate the transaction fee by simulating it on-chain. + /// + /// Falls back to default fee (5000 lamports) if estimation fails. + /// Note: Currently unused but kept for future implementation of proper fee estimation. + // ignore: unused_element + Future _estimateTransactionFee({ + required List messageBytes, + required RpcClient rpcClient, + }) async { + try { + final feeEstimate = await rpcClient.getFeeForMessage( + base64Encode(messageBytes), + commitment: Commitment.confirmed, + ); + + if (feeEstimate != null) { + return feeEstimate; + } + + // Fallback to default fee + return 5000; + } catch (e) { + Logging.instance.w( + "$runtimeType _estimateTransactionFee error: $e, using default fee", + ); + // Default fee: 5000 lamports + return 5000; + } + } + + /// Wait for transaction confirmation on-chain. + /// + /// Polls the RPC node until the transaction reaches the desired commitment + /// level or until timeout is reached. + /// + /// Returns true if confirmed, false if timeout or error occurred. + Future _waitForConfirmation({ + required String signature, + required int maxWaitSeconds, + required RpcClient rpcClient, + }) async { + final startTime = DateTime.now(); + + while (true) { + try { + final status = await rpcClient.getSignatureStatuses( + [signature], + searchTransactionHistory: true, + ); + + if (status.value.isNotEmpty) { + final txStatus = status.value.first; + + // Check if transaction failed + if (txStatus?.err != null) { + Logging.instance.e( + "$runtimeType Transaction failed: ${txStatus?.err}", + ); + return false; + } + + // Check if transaction confirmed + if (txStatus?.confirmationStatus == Commitment.confirmed || + txStatus?.confirmationStatus == Commitment.finalized) { + Logging.instance.i( + "$runtimeType Transaction confirmed: $signature", + ); + return true; + } + } + } catch (e) { + Logging.instance.w( + "$runtimeType Error checking transaction confirmation: $e", + ); + } + + // Check timeout + final elapsed = DateTime.now().difference(startTime).inSeconds; + if (elapsed > maxWaitSeconds) { + Logging.instance.w( + "$runtimeType Transaction confirmation timeout after $maxWaitSeconds seconds", + ); + return false; + } + + // Wait before next check (2 seconds) + await Future.delayed(const Duration(seconds: 2)); + } + } } From cda032bd56318f885bfd19fc2a06cfb35c14ea61 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 11 Nov 2025 12:27:00 -0600 Subject: [PATCH 29/80] fix(spl): race condition fix --- .../token_transaction_list_widget_sol.dart | 35 +++++++++++++++---- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/lib/pages/token_view/sub_widgets/token_transaction_list_widget_sol.dart b/lib/pages/token_view/sub_widgets/token_transaction_list_widget_sol.dart index d3640e841..ffbfb2e24 100644 --- a/lib/pages/token_view/sub_widgets/token_transaction_list_widget_sol.dart +++ b/lib/pages/token_view/sub_widgets/token_transaction_list_widget_sol.dart @@ -45,8 +45,8 @@ class _SolanaTransactionsListState extends ConsumerState _transactions = []; - late final StreamSubscription> _subscription; - late final Query _query; + StreamSubscription>? _subscription; + Query? _query; BorderRadius get _borderRadiusFirst { return BorderRadius.only( @@ -77,6 +77,14 @@ class _SolanaTransactionsListState extends ConsumerState value.getWallet(widget.walletId))); + // Ensure query is initialized when wallet becomes available. + _initializeQuery(); + + // If query hasn't been initialized yet, show loading. + if (_query == null) { + return Center( + child: Container( + color: Theme.of(context).extension()!.background, + child: const LoadingIndicator( + width: 100, + height: 100, + ), + ), + ); + } + return FutureBuilder( - future: _query.findAll(), + future: _query!.findAll(), builder: (fbContext, AsyncSnapshot> snapshot) { if (snapshot.connectionState == ConnectionState.done && snapshot.hasData) { From fdcde9dc9dd17997b981c71b207c50384811b5ea Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 11 Nov 2025 12:27:40 -0600 Subject: [PATCH 30/80] feat(spl): desktop sol token send --- .../wallet_view/desktop_sol_token_view.dart | 21 +- .../sub_widgets/desktop_sol_token_send.dart | 282 ++++++++---------- .../wallet_view/sub_widgets/my_wallet.dart | 10 +- 3 files changed, 144 insertions(+), 169 deletions(-) diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart index eb31dd157..c5b1ab639 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart @@ -13,6 +13,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; import '../../../pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart'; +import '../../../pages/token_view/sub_widgets/token_transaction_list_widget_sol.dart'; import '../../../providers/providers.dart'; import '../../../services/event_bus/events/global/wallet_sync_status_changed_event.dart'; import '../../../themes/stack_colors.dart'; @@ -20,6 +21,7 @@ import '../../../utilities/assets.dart'; import '../../../utilities/default_spl_tokens.dart'; import '../../../utilities/text_styles.dart'; import '../../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; +import '../../../wallets/isar/providers/solana/solana_wallet_provider.dart'; import '../../../wallets/isar/providers/wallet_info_provider.dart'; import '../../../wallets/wallet/impl/sub_wallets/solana_token_wallet.dart'; import '../../../widgets/coin_ticker_tag.dart'; @@ -95,7 +97,19 @@ class _DesktopTokenViewState extends ConsumerState { return; } + // Get the parent Solana wallet. + final parentWallet = ref.read(pSolanaWallet(widget.walletId)); + + if (parentWallet == null) { + ref.read(solanaTokenServiceStateProvider.state).state = null; + debugPrint( + 'ERROR: Wallet is not a SolanaWallet: ${widget.walletId}', + ); + return; + } + final solanaTokenWallet = SolanaTokenWallet( + parentSolanaWallet: parentWallet, tokenMint: widget.tokenMint, tokenName: "${tokenInfo.name}", tokenSymbol: "${tokenInfo.symbol}", @@ -252,11 +266,8 @@ class _DesktopTokenViewState extends ConsumerState { ), const SizedBox(width: 16), Expanded( - child: Center( - child: Text( - "WIP", // TODO [prio=high]: Implement. - style: STextStyles.itemSubtitle(context), - ), + child: SolanaTokenTransactionsList( + walletId: widget.walletId, ), ), ], diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart index bf57331ea..c35e346b3 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart @@ -21,23 +21,20 @@ import '../../../../models/send_view_auto_fill_data.dart'; import '../../../../pages/send_view/confirm_transaction_view.dart'; import '../../../../pages/send_view/sub_widgets/building_transaction_dialog.dart'; import '../../../../providers/providers.dart'; -import '../../../../providers/ui/fee_rate_type_state_provider.dart'; import '../../../../providers/ui/preview_tx_button_state_provider.dart'; -import '../../../../providers/wallet/desktop_fee_providers.dart'; import '../../../../themes/stack_colors.dart'; import '../../../../utilities/address_utils.dart'; import '../../../../utilities/amount/amount.dart'; import '../../../../utilities/amount/amount_formatter.dart'; import '../../../../utilities/amount/amount_input_formatter.dart'; -import '../../../../utilities/amount/amount_unit.dart'; import '../../../../utilities/clipboard_interface.dart'; import '../../../../utilities/constants.dart'; import '../../../../utilities/logger.dart'; import '../../../../utilities/text_styles.dart'; import '../../../../utilities/util.dart'; import '../../../../wallets/crypto_currency/crypto_currency.dart'; -import '../../../../wallets/isar/providers/eth/current_token_wallet_provider.dart'; -import '../../../../wallets/isar/providers/eth/token_balance_provider.dart'; +import '../../../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; +import '../../../../wallets/isar/providers/solana/sol_token_balance_provider.dart'; import '../../../../wallets/models/tx_data.dart'; import '../../../../widgets/custom_buttons/blue_text_button.dart'; import '../../../../widgets/desktop/desktop_dialog.dart'; @@ -45,7 +42,6 @@ import '../../../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../../../widgets/desktop/primary_button.dart'; import '../../../../widgets/desktop/qr_code_scanner_dialog.dart'; import '../../../../widgets/desktop/secondary_button.dart'; -import '../../../../widgets/eth_fee_form.dart'; import '../../../../widgets/icon_widgets/addressbook_icon.dart'; import '../../../../widgets/icon_widgets/clipboard_icon.dart'; import '../../../../widgets/icon_widgets/x_icon.dart'; @@ -53,10 +49,9 @@ import '../../../../widgets/stack_text_field.dart'; import '../../../../widgets/textfield_icon_button.dart'; import '../../../desktop_home_view.dart'; import 'address_book_address_chooser/address_book_address_chooser.dart'; -import 'desktop_send_fee_form.dart'; -class DesktopTokenSend extends ConsumerStatefulWidget { - const DesktopTokenSend({ +class DesktopSolTokenSend extends ConsumerStatefulWidget { + const DesktopSolTokenSend({ super.key, required this.walletId, this.autoFillData, @@ -71,10 +66,10 @@ class DesktopTokenSend extends ConsumerStatefulWidget { final PaynymAccountLite? accountLite; @override - ConsumerState createState() => _DesktopTokenSendState(); + ConsumerState createState() => _DesktopSolTokenSendState(); } -class _DesktopTokenSendState extends ConsumerState { +class _DesktopSolTokenSendState extends ConsumerState { late final String walletId; late final CryptoCurrency coin; late final ClipboardInterface clipboard; @@ -89,7 +84,8 @@ class _DesktopTokenSendState extends ConsumerState { final _addressFocusNode = FocusNode(); final _cryptoFocus = FocusNode(); final _baseFocus = FocusNode(); - final _nonceFocusNode = FocusNode(); + // Solana doesn't use nonces like Ethereum. + // final _nonceFocusNode = FocusNode(); String? _note; @@ -102,21 +98,32 @@ class _DesktopTokenSendState extends ConsumerState { bool _cryptoAmountChangeLock = false; late VoidCallback onCryptoAmountChanged; - EthEIP1559Fee? ethFee; - Future previewSend() async { - final tokenWallet = ref.read(pCurrentTokenWallet)!; + final tokenWallet = ref.read(pCurrentSolanaTokenWallet)!; final Amount amount = _amountToSend!; - final Amount availableBalance = - ref - .read( - pTokenBalance(( - walletId: walletId, - contractAddress: tokenWallet.tokenContract.address, - )), - ) - .spendable; + + // Get the current balance (already cached from UI display). + final balanceAsyncValue = ref.read( + pSolanaTokenBalance(( + walletId: walletId, + tokenMint: tokenWallet.tokenMint, + fractionDigits: tokenWallet.tokenDecimals, + )), + ); + + late Amount availableBalance; + balanceAsyncValue.when( + data: (balance) { + availableBalance = balance.spendable; + }, + error: (error, stackTrace) { + throw Exception('Failed to fetch balance: $error'); + }, + loading: () { + throw Exception('Balance is still loading'); + }, + ); // confirm send all if (amount == availableBalance) { @@ -229,6 +236,10 @@ class _DesktopTokenSendState extends ConsumerState { TxData txData; Future txDataFuture; + + final tokenSymbol = tokenWallet.tokenSymbol; + final tokenMint = tokenWallet.tokenMint; + final tokenDecimals = tokenWallet.tokenDecimals; txDataFuture = tokenWallet.prepareSend( txData: TxData( @@ -241,9 +252,9 @@ class _DesktopTokenSendState extends ConsumerState { tokenWallet.cryptoCurrency.getAddressType(_address!)!, ), ], - feeRateType: ref.read(feeRateTypeDesktopStateProvider), - nonce: int.tryParse(nonceController.text), - ethEIP1559Fee: ethFee, + tokenSymbol: tokenSymbol, + tokenMint: tokenMint, + tokenDecimals: tokenDecimals, ), ); @@ -252,7 +263,12 @@ class _DesktopTokenSendState extends ConsumerState { txData = results.first as TxData; if (!wasCancelled && mounted) { - txData = txData.copyWith(note: _note ?? ""); + txData = txData.copyWith( + note: _note ?? "", + tokenSymbol: tokenSymbol, + tokenMint: tokenMint, + tokenDecimals: tokenDecimals, + ); // pop building dialog Navigator.of(context, rootNavigator: true).pop(); @@ -346,7 +362,7 @@ class _DesktopTokenSendState extends ConsumerState { sendToController.text = ""; cryptoAmountController.text = ""; baseAmountController.text = ""; - nonceController.text = ""; + // Note: Solana doesn't use nonces like Ethereum. _address = ""; _addressToggleFlag = false; if (mounted) { @@ -356,38 +372,55 @@ class _DesktopTokenSendState extends ConsumerState { void _cryptoAmountChanged() async { if (!_cryptoAmountChangeLock) { - final cryptoAmount = ref - .read(pAmountFormatter(coin)) - .tryParse( - cryptoAmountController.text, - ethContract: ref.read(pCurrentTokenWallet)!.tokenContract, + // Get the token's decimal places for proper amount parsing + final tokenDecimals = + ref.read(pCurrentSolanaTokenWallet)!.tokenDecimals; + + if (cryptoAmountController.text.isNotEmpty && + cryptoAmountController.text != "." && + cryptoAmountController.text != ",") { + try { + // Parse the amount using the token's decimal places, not the coin's + final inputDecimal = Decimal.parse( + cryptoAmountController.text.replaceFirst(",", "."), + ); + final cryptoAmount = Amount.fromDecimal( + inputDecimal, + fractionDigits: tokenDecimals, ); - if (cryptoAmount != null) { - _amountToSend = cryptoAmount; - if (_cachedAmountToSend != null && - _cachedAmountToSend == _amountToSend) { - return; - } - _cachedAmountToSend = _amountToSend; + // Only proceed if the parsed amount is valid + if (cryptoAmount.raw > BigInt.zero) { + _amountToSend = cryptoAmount; + if (_cachedAmountToSend != null && + _cachedAmountToSend == _amountToSend) { + return; + } + _cachedAmountToSend = _amountToSend; - final price = - ref + final price = ref .read(priceAnd24hChangeNotifierProvider) .getTokenPrice( - ref.read(pCurrentTokenWallet)!.tokenContract.address, + ref.read(pCurrentSolanaTokenWallet)!.tokenMint, ) ?.value; - if (price != null && price > Decimal.zero) { - final String fiatAmountString = Amount.fromDecimal( - _amountToSend!.decimal * price, - fractionDigits: 2, - ).fiatString( - locale: ref.read(localeServiceChangeNotifierProvider).locale, - ); + if (price != null && price > Decimal.zero) { + final String fiatAmountString = Amount.fromDecimal( + _amountToSend!.decimal * price, + fractionDigits: 2, + ).fiatString( + locale: ref.read(localeServiceChangeNotifierProvider).locale, + ); - baseAmountController.text = fiatAmountString; + baseAmountController.text = fiatAmountString; + } + } + } catch (e) { + // Probably an invalid decimal input. + _amountToSend = null; + _cachedAmountToSend = null; + baseAmountController.text = ""; } } else { _amountToSend = null; @@ -465,7 +498,7 @@ class _DesktopTokenSendState extends ConsumerState { if (paymentData.amount != null) { final Amount amount = Decimal.parse(paymentData.amount!).toAmount( fractionDigits: - ref.read(pCurrentTokenWallet)!.tokenContract.decimals, + ref.read(pCurrentSolanaTokenWallet)!.tokenDecimals, ); cryptoAmountController.text = ref .read(pAmountFormatter(coin)) @@ -520,7 +553,7 @@ class _DesktopTokenSendState extends ConsumerState { void fiatTextFieldOnChanged(String baseAmountString) { final int tokenDecimals = - ref.read(pCurrentTokenWallet)!.tokenContract.decimals; + ref.read(pCurrentSolanaTokenWallet)!.tokenDecimals; if (baseAmountString.isNotEmpty && baseAmountString != "." && @@ -536,7 +569,7 @@ class _DesktopTokenSendState extends ConsumerState { ref .read(priceAnd24hChangeNotifierProvider) .getTokenPrice( - ref.read(pCurrentTokenWallet)!.tokenContract.address, + ref.read(pCurrentSolanaTokenWallet)!.tokenMint, ) ?.value; @@ -560,7 +593,6 @@ class _DesktopTokenSendState extends ConsumerState { .format( _amountToSend!, withUnitName: false, - ethContract: ref.read(pCurrentTokenWallet)!.tokenContract, ); _cryptoAmountChangeLock = true; @@ -577,36 +609,50 @@ class _DesktopTokenSendState extends ConsumerState { } Future sendAllTapped() async { - cryptoAmountController.text = ref - .read( - pTokenBalance(( - walletId: walletId, - contractAddress: - ref.read(pCurrentTokenWallet)!.tokenContract.address, - )), - ) - .spendable - .decimal - .toStringAsFixed(ref.read(pCurrentTokenWallet)!.tokenContract.decimals); + final tokenWallet = ref.read(pCurrentSolanaTokenWallet)!; + final balanceAsyncValue = ref.read( + pSolanaTokenBalance(( + walletId: walletId, + tokenMint: tokenWallet.tokenMint, + fractionDigits: tokenWallet.tokenDecimals, + )), + ); + + balanceAsyncValue.when( + data: (balance) { + cryptoAmountController.text = balance + .spendable + .decimal + .toStringAsFixed(tokenWallet.tokenDecimals); + }, + error: (error, stackTrace) { + Logging.instance.e('Failed to fetch balance for send all: $error'); + }, + loading: () { + // Should not happen with read. + }, + ); } @override void initState() { WidgetsBinding.instance.addPostFrameCallback((_) { - ref.refresh(tokenFeeSessionCacheProvider); + // ref.refresh(tokenFeeSessionCacheProvider); // Ethereum-specific ref.read(previewTokenTxButtonStateProvider.state).state = false; }); // _calculateFeesFuture = calculateFees(0); _data = widget.autoFillData; walletId = widget.walletId; - coin = ref.read(pWallets).getWallet(walletId).info.coin; + final wallet = ref.read(pWallets).getWallet(walletId); + coin = wallet.info.coin; clipboard = widget.clipboard; sendToController = TextEditingController(); cryptoAmountController = TextEditingController(); baseAmountController = TextEditingController(); - nonceController = TextEditingController(); + // Solana doesn't use nonces like Ethereum. + // nonceController = TextEditingController(); // feeController = TextEditingController(); onCryptoAmountChanged = _cryptoAmountChanged; @@ -621,30 +667,6 @@ class _DesktopTokenSendState extends ConsumerState { _addressToggleFlag = true; } - _cryptoFocus.addListener(() { - if (!_cryptoFocus.hasFocus && !_baseFocus.hasFocus) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_amountToSend == null) { - ref.refresh(sendAmountProvider); - } else { - ref.read(sendAmountProvider.state).state = _amountToSend!; - } - }); - } - }); - - _baseFocus.addListener(() { - if (!_cryptoFocus.hasFocus && !_baseFocus.hasFocus) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_amountToSend == null) { - ref.refresh(sendAmountProvider); - } else { - ref.read(sendAmountProvider.state).state = _amountToSend!; - } - }); - } - }); - super.initState(); } @@ -655,13 +677,13 @@ class _DesktopTokenSendState extends ConsumerState { sendToController.dispose(); cryptoAmountController.dispose(); baseAmountController.dispose(); - nonceController.dispose(); + // nonceController.dispose(); // Solana doesn't use nonces. // feeController.dispose(); _addressFocusNode.dispose(); _cryptoFocus.dispose(); _baseFocus.dispose(); - _nonceFocusNode.dispose(); + // _nonceFocusNode.dispose(); // Solana doesn't use nonces. super.dispose(); } @@ -669,7 +691,7 @@ class _DesktopTokenSendState extends ConsumerState { Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType"); - final tokenContract = ref.watch(pCurrentTokenWallet)!.tokenContract; + final tokenWallet = ref.watch(pCurrentSolanaTokenWallet)!; return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -700,7 +722,7 @@ class _DesktopTokenSendState extends ConsumerState { textAlign: TextAlign.left, ), CustomTextButton( - text: "Send all ${tokenContract.symbol}", + text: "Send all ${tokenWallet.tokenSymbol}", onTap: sendAllTapped, ), ], @@ -725,7 +747,7 @@ class _DesktopTokenSendState extends ConsumerState { textAlign: TextAlign.right, inputFormatters: [ AmountInputFormatter( - decimals: tokenContract.decimals, + decimals: tokenWallet.tokenDecimals, unit: ref.watch(pAmountUnit(coin)), locale: ref.watch( localeServiceChangeNotifierProvider.select( @@ -762,7 +784,7 @@ class _DesktopTokenSendState extends ConsumerState { child: Padding( padding: const EdgeInsets.all(12), child: Text( - ref.watch(pAmountUnit(coin)).unitForContract(tokenContract), + tokenWallet.tokenSymbol, style: STextStyles.smallMed14(context).copyWith( color: Theme.of( @@ -900,7 +922,7 @@ class _DesktopTokenSendState extends ConsumerState { height: 1.8, ), decoration: standardInputDecoration( - "Enter ${tokenContract.symbol} address", + "Enter Solana address", _addressFocusNode, context, desktopMed: true, @@ -1040,64 +1062,6 @@ class _DesktopTokenSendState extends ConsumerState { } }, ), - const SizedBox(height: 20), - DesktopSendFeeForm( - walletId: walletId, - isToken: true, - onCustomFeeSliderChanged: (value) => {}, - onCustomFeeOptionChanged: (value) { - ethFee = null; - }, - onCustomEip1559FeeOptionChanged: (value) => ethFee = value, - ), - const SizedBox(height: 20), - Text( - "Nonce", - style: STextStyles.desktopTextExtraSmall(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textFieldActiveSearchIconRight, - ), - textAlign: TextAlign.left, - ), - const SizedBox(height: 10), - ClipRRect( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - child: TextField( - minLines: 1, - maxLines: 1, - key: const Key("sendViewNonceFieldKey"), - controller: nonceController, - readOnly: false, - autocorrect: false, - enableSuggestions: false, - keyboardType: const TextInputType.numberWithOptions(), - focusNode: _nonceFocusNode, - style: STextStyles.desktopTextExtraSmall(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textFieldActiveText, - height: 1.8, - ), - decoration: standardInputDecoration( - "Leave empty to auto select nonce", - _nonceFocusNode, - context, - desktopMed: true, - ).copyWith( - contentPadding: const EdgeInsets.only( - left: 16, - top: 11, - bottom: 12, - right: 5, - ), - ), - ), - ), const SizedBox(height: 36), PrimaryButton( buttonHeight: ButtonHeight.l, diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/my_wallet.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/my_wallet.dart index 9585cf9c4..1c490bea2 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/my_wallet.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/my_wallet.dart @@ -16,6 +16,7 @@ import '../../../../pages/finalize_view/finalize_view.dart'; import '../../../../pages/send_view/frost_ms/frost_send_view.dart'; import '../../../../pages/wallet_view/transaction_views/tx_v2/transaction_v2_list.dart'; import '../../../../providers/global/wallets_provider.dart'; +import '../../../../utilities/clipboard_interface.dart'; import '../../../../wallets/crypto_currency/crypto_currency.dart'; import '../../../../wallets/wallet/impl/bitcoin_frost_wallet.dart'; import '../../../../wallets/wallet/impl/solana_wallet.dart' show SolanaWallet; @@ -27,6 +28,7 @@ import '../../../../widgets/rounded_white_container.dart'; import '../../my_stack_view.dart'; import 'desktop_receive.dart'; import 'desktop_send.dart'; +import 'desktop_sol_token_send.dart'; import 'desktop_token_send.dart'; class MyWallet extends ConsumerStatefulWidget { @@ -166,11 +168,9 @@ class _MyWalletState extends ConsumerState { : Padding( padding: const EdgeInsets.all(20), child: isSolana - ? Center( - child: Text( - "WIP", // TODO [prio=high]: Implement. - style: Theme.of(context).textTheme.bodyMedium, - ), + ? DesktopSolTokenSend( + walletId: widget.walletId, + clipboard: const ClipboardWrapper(), ) : DesktopTokenSend(walletId: widget.walletId), ), From 8ea00dfa214ce0c29b42e8da7ad56662fd8a4955 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 11 Nov 2025 12:28:49 -0600 Subject: [PATCH 31/80] fix(spl): amount formatting --- .../send_view/confirm_transaction_view.dart | 99 ++++++++++++++----- 1 file changed, 77 insertions(+), 22 deletions(-) diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index 5cb12e02a..ee593154c 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -17,6 +17,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; +import '../../models/isar/models/solana/spl_token.dart'; import '../../models/isar/models/transaction_note.dart'; import '../../notifications/show_flush_bar.dart'; import '../../pages_desktop_specific/coin_control/desktop_coin_control_use_dialog.dart'; @@ -37,10 +38,12 @@ import '../../wallets/crypto_currency/coins/ethereum.dart'; import '../../wallets/crypto_currency/coins/mimblewimblecoin.dart'; import '../../wallets/crypto_currency/intermediate/nano_currency.dart'; import '../../wallets/isar/providers/eth/current_token_wallet_provider.dart'; +import '../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; import '../../wallets/isar/providers/wallet_info_provider.dart'; import '../../wallets/models/tx_data.dart'; import '../../wallets/wallet/impl/firo_wallet.dart'; import '../../wallets/wallet/impl/mimblewimblecoin_wallet.dart'; +import '../../wallets/wallet/impl/solana_wallet.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart'; import '../../widgets/background.dart'; import '../../widgets/conditional_parent.dart'; @@ -214,9 +217,17 @@ class _ConfirmTransactionViewState try { if (widget.isTokenTx) { - txDataFuture = ref - .read(pCurrentTokenWallet)! - .confirmSend(txData: widget.txData); + if (wallet is SolanaWallet) { + // For Solana tokens, use the Solana token wallet. + txDataFuture = ref + .read(pCurrentSolanaTokenWallet)! + .confirmSend(txData: widget.txData); + } else { + // For Ethereum tokens, use the Ethereum token wallet. + txDataFuture = ref + .read(pCurrentTokenWallet)! + .confirmSend(txData: widget.txData); + } } else if (widget.isPaynymNotificationTransaction) { txDataFuture = (wallet as PaynymInterface).broadcastNotificationTx( txData: widget.txData, @@ -301,7 +312,11 @@ class _ConfirmTransactionViewState } if (widget.isTokenTx) { - unawaited(ref.read(pCurrentTokenWallet)!.refresh()); + if (wallet is SolanaWallet) { + unawaited(ref.read(pCurrentSolanaTokenWallet)!.refresh()); + } else { + unawaited(ref.read(pCurrentTokenWallet)!.refresh()); + } } else { unawaited(wallet.refresh()); } @@ -439,10 +454,19 @@ class _ConfirmTransactionViewState final coin = ref.watch(pWalletCoin(walletId)); final String unit; + final wallet = ref.watch(pWallets).getWallet(walletId); if (widget.isTokenTx) { - unit = ref.watch( - pCurrentTokenWallet.select((value) => value!.tokenContract.symbol), - ); + if (wallet is SolanaWallet) { + // For Solana tokens, use the Solana token wallet provider or TxData as fallback. + unit = ref.watch( + pCurrentSolanaTokenWallet.select((value) => value?.tokenSymbol), + ) ?? widget.txData.tokenSymbol ?? "TOKEN"; + } else { + // For Ethereum tokens, use the Ethereum token wallet provider. + unit = ref.watch( + pCurrentTokenWallet.select((value) => value!.tokenContract.symbol), + ); + } } else { unit = coin.ticker; } @@ -450,8 +474,6 @@ class _ConfirmTransactionViewState final Amount? fee; final Amount amountWithoutChange; - final wallet = ref.watch(pWallets).getWallet(walletId); - if (wallet is FiroWallet) { switch (ref.read(publicPrivateBalanceStateProvider.state).state) { case BalanceType.public: @@ -604,11 +626,19 @@ class _ConfirmTransactionViewState .watch(pAmountFormatter(coin)) .format( amountWithoutChange, - ethContract: widget.isTokenTx + ethContract: widget.isTokenTx && wallet is! SolanaWallet ? ref .watch(pCurrentTokenWallet)! .tokenContract : null, + splToken: widget.isTokenTx && wallet is SolanaWallet + ? SplToken( + address: widget.txData.tokenMint ?? "unknown", + name: widget.txData.tokenSymbol ?? "Token", + symbol: widget.txData.tokenSymbol ?? "TOKEN", + decimals: widget.txData.tokenDecimals ?? 9, + ) + : null, ), style: STextStyles.itemSubtitle12(context), textAlign: TextAlign.right, @@ -794,17 +824,34 @@ class _ConfirmTransactionViewState if (externalCalls) { final price = widget.isTokenTx - ? ref - .read( - priceAnd24hChangeNotifierProvider, - ) - .getTokenPrice( - ref - .read(pCurrentTokenWallet)! - .tokenContract - .address, - ) - ?.value + ? (wallet is SolanaWallet + ? // For Solana tokens, use tokenMint from provider or TxData. + ref + .read( + priceAnd24hChangeNotifierProvider, + ) + .getTokenPrice( + ref + .read( + pCurrentSolanaTokenWallet, + ) + ?.tokenMint ?? + widget.txData.tokenMint ?? + "unknown", + ) + ?.value + : // For Ethereum tokens, use contract address. + ref + .read( + priceAnd24hChangeNotifierProvider, + ) + .getTokenPrice( + ref + .read(pCurrentTokenWallet)! + .tokenContract + .address, + ) + ?.value) : ref .read( priceAnd24hChangeNotifierProvider, @@ -832,13 +879,21 @@ class _ConfirmTransactionViewState .watch(pAmountFormatter(coin)) .format( amountWithoutChange, - ethContract: widget.isTokenTx + ethContract: widget.isTokenTx && wallet is! SolanaWallet ? ref .watch( pCurrentTokenWallet, )! .tokenContract : null, + splToken: widget.isTokenTx && wallet is SolanaWallet + ? SplToken( + address: widget.txData.tokenMint ?? "unknown", + name: widget.txData.tokenSymbol ?? "Token", + symbol: widget.txData.tokenSymbol ?? "TOKEN", + decimals: widget.txData.tokenDecimals ?? 9, + ) + : null, ), style: STextStyles.desktopTextExtraExtraSmall( From 81ebbf6408e0a88b30726f4dc7b5cea9c0677386 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 11 Nov 2025 12:33:17 -0600 Subject: [PATCH 32/80] fix(spl): pass parent sol wallet of child token wallet --- lib/pages/token_view/sol_token_view.dart | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/pages/token_view/sol_token_view.dart b/lib/pages/token_view/sol_token_view.dart index 8bcb5bd4c..b7812ee04 100644 --- a/lib/pages/token_view/sol_token_view.dart +++ b/lib/pages/token_view/sol_token_view.dart @@ -12,6 +12,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; +import '../../providers/providers.dart'; import '../../services/event_bus/events/global/wallet_sync_status_changed_event.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; @@ -19,6 +20,7 @@ import '../../utilities/constants.dart'; import '../../utilities/default_spl_tokens.dart'; import '../../utilities/text_styles.dart'; import '../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; +import '../../wallets/isar/providers/solana/solana_wallet_provider.dart'; import '../../wallets/wallet/impl/sub_wallets/solana_token_wallet.dart'; import '../../widgets/background.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; @@ -96,7 +98,19 @@ class _SolTokenViewState extends ConsumerState { return; } + // Get the parent Solana wallet. + final parentWallet = ref.read(pSolanaWallet(widget.walletId)); + + if (parentWallet == null) { + ref.read(solanaTokenServiceStateProvider.state).state = null; + debugPrint( + 'ERROR: Wallet is not a SolanaWallet: ${widget.walletId}', + ); + return; + } + final solanaTokenWallet = SolanaTokenWallet( + parentSolanaWallet: parentWallet, tokenMint: widget.tokenMint, tokenName: "${tokenInfo.name}", tokenSymbol: "${tokenInfo.symbol}", From cd97339fd8203969fba44132cb8e267d68b78d04 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 11 Nov 2025 14:54:18 -0600 Subject: [PATCH 33/80] feat(spl): sol token fee estimation --- .../impl/sub_wallets/solana_token_wallet.dart | 74 ++++++++++++++----- 1 file changed, 54 insertions(+), 20 deletions(-) diff --git a/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart b/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart index 6239c48e2..508ebdf58 100644 --- a/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart +++ b/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart @@ -161,10 +161,14 @@ class SolanaTokenWallet extends Wallet { amount: txData.amount!.raw.toInt(), ); - // Estimate fee. - // For now, use a default fee estimate. - // TODO: Implement proper fee estimation using compiled message. - const feeEstimate = 5000; + // Estimate fee using RPC call. + final feeEstimate = await _getEstimatedTokenTransferFee( + senderTokenAccountKey: senderTokenAccountKey, + recipientTokenAccountKey: recipientTokenAccountKey, + ownerPublicKey: keyPair.publicKey, + amount: txData.amount!.raw.toInt(), + rpcClient: rpcClient, + ) ?? 5000; // Return prepared TxData. return txData.copyWith( @@ -331,14 +335,16 @@ class SolanaTokenWallet extends Wallet { @override Future estimateFeeFor(Amount amount, BigInt feeRate) async { - // Mock fee estimation: 5000 lamports for token transfer. - return Amount.zeroWith(fractionDigits: tokenDecimals); + // Delegate to parent SolanaWallet for fee estimation. + // For token transfers, the fee is the same as a regular SOL transfer. + return parentSolanaWallet.estimateFeeFor(amount, feeRate); } @override Future get fees async { - // TODO: Return real Solana fee estimates. - throw UnimplementedError("fees not yet implemented"); + // Delegate to parent SolanaWallet for fee information. + // For token transfers, the fees are the same as regular SOL transfers. + return parentSolanaWallet.fees; } @override @@ -475,33 +481,61 @@ class SolanaTokenWallet extends Wallet { } } - /// Estimate the transaction fee by simulating it on-chain. + /// Estimate the fee for an SPL token transfer transaction. + /// + /// Builds a token transfer message with the given parameters and uses + /// the RPC `getFeeForMessage` call to get an accurate fee estimate. /// - /// Falls back to default fee (5000 lamports) if estimation fails. - /// Note: Currently unused but kept for future implementation of proper fee estimation. - // ignore: unused_element - Future _estimateTransactionFee({ - required List messageBytes, + /// Returns the estimated fee in lamports, or null if estimation fails. + Future _getEstimatedTokenTransferFee({ + required Ed25519HDPublicKey senderTokenAccountKey, + required Ed25519HDPublicKey recipientTokenAccountKey, + required Ed25519HDPublicKey ownerPublicKey, + required int amount, required RpcClient rpcClient, }) async { try { + // Get latest blockhash for message compilation. + final latestBlockhash = await rpcClient.getLatestBlockhash(); + + // Build the token transfer instruction. + final instruction = TokenInstruction.transfer( + source: senderTokenAccountKey, + destination: recipientTokenAccountKey, + owner: ownerPublicKey, + amount: amount, + ); + + // Compile the message with the blockhash. + final compiledMessage = Message( + instructions: [instruction], + ).compile( + recentBlockhash: latestBlockhash.value.blockhash, + feePayer: ownerPublicKey, + ); + + // Get the fee for this compiled message. final feeEstimate = await rpcClient.getFeeForMessage( - base64Encode(messageBytes), + base64Encode(compiledMessage.toByteArray().toList()), commitment: Commitment.confirmed, ); if (feeEstimate != null) { + Logging.instance.i( + "$runtimeType Estimated token transfer fee: $feeEstimate lamports (from RPC)", + ); return feeEstimate; } - // Fallback to default fee - return 5000; + Logging.instance.w( + "$runtimeType getFeeForMessage returned null", + ); + return null; } catch (e) { Logging.instance.w( - "$runtimeType _estimateTransactionFee error: $e, using default fee", + "$runtimeType _getEstimatedTokenTransferFee error: $e", ); - // Default fee: 5000 lamports - return 5000; + return null; } } From 146a15721b84926165cc787bc70937be8b742b68 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 11 Nov 2025 15:20:31 -0600 Subject: [PATCH 34/80] feat(spl): fetch sol token balance on refresh --- .../impl/sub_wallets/solana_token_wallet.dart | 146 +++++++++++------- 1 file changed, 93 insertions(+), 53 deletions(-) diff --git a/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart b/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart index 508ebdf58..7e77780d3 100644 --- a/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart +++ b/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart @@ -9,12 +9,12 @@ import 'dart:convert'; -import 'package:crypto/crypto.dart'; import 'package:isar_community/isar.dart'; import 'package:solana/dto.dart'; import 'package:solana/solana.dart' hide Wallet; import '../../../../models/paymint/fee_object_model.dart'; +import '../../../../services/solana/solana_token_api.dart'; import '../../../../utilities/amount/amount.dart'; import '../../../../utilities/logger.dart'; import '../../../crypto_currency/crypto_currency.dart'; @@ -58,10 +58,7 @@ class SolanaTokenWallet extends Wallet { @override FilterOperation? get transactionFilterOperation => - FilterCondition.equalTo( - property: r"contractAddress", - value: tokenMint, - ); + FilterCondition.equalTo(property: r"contractAddress", value: tokenMint); @override Future init() async { @@ -147,10 +144,12 @@ class SolanaTokenWallet extends Wallet { ); // Build SPL token tx instruction. - final senderTokenAccountKey = - Ed25519HDPublicKey.fromBase58(senderTokenAccount); - final recipientTokenAccountKey = - Ed25519HDPublicKey.fromBase58(recipientTokenAccount); + final senderTokenAccountKey = Ed25519HDPublicKey.fromBase58( + senderTokenAccount, + ); + final recipientTokenAccountKey = Ed25519HDPublicKey.fromBase58( + recipientTokenAccount, + ); // Build the transfer instruction (validated later in confirmSend). // ignore: unused_local_variable @@ -162,13 +161,15 @@ class SolanaTokenWallet extends Wallet { ); // Estimate fee using RPC call. - final feeEstimate = await _getEstimatedTokenTransferFee( - senderTokenAccountKey: senderTokenAccountKey, - recipientTokenAccountKey: recipientTokenAccountKey, - ownerPublicKey: keyPair.publicKey, - amount: txData.amount!.raw.toInt(), - rpcClient: rpcClient, - ) ?? 5000; + final feeEstimate = + await _getEstimatedTokenTransferFee( + senderTokenAccountKey: senderTokenAccountKey, + recipientTokenAccountKey: recipientTokenAccountKey, + ownerPublicKey: keyPair.publicKey, + amount: txData.amount!.raw.toInt(), + rpcClient: rpcClient, + ) ?? + 5000; // Return prepared TxData. return txData.copyWith( @@ -193,9 +194,7 @@ class SolanaTokenWallet extends Wallet { try { // Validate that prepareSend was called. if (txData.fee == null) { - throw Exception( - "Transaction not prepared. Call prepareSend() first.", - ); + throw Exception("Transaction not prepared. Call prepareSend() first."); } if (txData.recipients == null || txData.recipients!.isEmpty) { @@ -242,10 +241,12 @@ class SolanaTokenWallet extends Wallet { ); // 5. Build SPL token tx instruction. - final senderTokenAccountKey = - Ed25519HDPublicKey.fromBase58(senderTokenAccount); - final recipientTokenAccountKey = - Ed25519HDPublicKey.fromBase58(recipientTokenAccount); + final senderTokenAccountKey = Ed25519HDPublicKey.fromBase58( + senderTokenAccount, + ); + final recipientTokenAccountKey = Ed25519HDPublicKey.fromBase58( + recipientTokenAccount, + ); final instruction = TokenInstruction.transfer( source: senderTokenAccountKey, @@ -255,18 +256,15 @@ class SolanaTokenWallet extends Wallet { ); // Create message. - final message = Message( - instructions: [instruction], - ); + final message = Message(instructions: [instruction]); // Sign and broadcast tx. - final txid = await rpcClient.signAndSendTransaction( - message, - [keyPair], - ); + final txid = await rpcClient.signAndSendTransaction(message, [keyPair]); if (txid.isEmpty) { - throw Exception("Failed to broadcast transaction: empty signature returned"); + throw Exception( + "Failed to broadcast transaction: empty signature returned", + ); } // Wait for confirmation. @@ -312,7 +310,60 @@ class SolanaTokenWallet extends Wallet { @override Future updateBalance() async { - // TODO: Fetch token balance from Solana RPC. + try { + final rpcClient = parentSolanaWallet.getRpcClient(); + if (rpcClient == null) { + Logging.instance.w( + "$runtimeType updateBalance: RPC client not initialized", + ); + return; + } + + final keyPair = await parentSolanaWallet.getKeyPair(); + final walletAddress = keyPair.address; + + // Get sender's token account. + final senderTokenAccount = await _findTokenAccount( + ownerAddress: walletAddress, + mint: tokenMint, + rpcClient: rpcClient, + ); + + if (senderTokenAccount == null) { + Logging.instance.w( + "$runtimeType updateBalance: No token account found for mint $tokenMint", + ); + return; + } + + // Fetch the token balance. + final tokenApi = SolanaTokenAPI(); + tokenApi.initializeRpcClient(rpcClient); + + final balanceResponse = await tokenApi.getTokenAccountBalance( + senderTokenAccount, + ); + + if (balanceResponse.isError) { + Logging.instance.w( + "$runtimeType updateBalance failed: ${balanceResponse.exception}", + ); + return; + } + + if (balanceResponse.value != null) { + // Log the updated balance. + Logging.instance.i( + "$runtimeType updateBalance: New balance = ${balanceResponse.value} (${balanceResponse.value! / BigInt.from(10).pow(tokenDecimals)} ${tokenSymbol})", + ); + } + } catch (e, s) { + Logging.instance.e( + "$runtimeType updateBalance error: ", + error: e, + stackTrace: s, + ); + } } @override @@ -349,8 +400,8 @@ class SolanaTokenWallet extends Wallet { @override Future pingCheck() async { - // TODO: Check Solana RPC connection. - return true; + // Delegate to parent SolanaWallet for RPC health check. + return parentSolanaWallet.pingCheck(); } @override @@ -384,9 +435,7 @@ class SolanaTokenWallet extends Wallet { // Return the first token account address return result.value.first.pubkey; } catch (e) { - Logging.instance.w( - "$runtimeType _findTokenAccount error: $e", - ); + Logging.instance.w("$runtimeType _findTokenAccount error: $e"); return null; } } @@ -428,9 +477,7 @@ class SolanaTokenWallet extends Wallet { mint: mint, ); final ataBase58 = ataAddress.toBase58(); - Logging.instance.i( - "$runtimeType Derived ATA address: $ataBase58", - ); + Logging.instance.i("$runtimeType Derived ATA address: $ataBase58"); return ataBase58; } catch (derivationError) { Logging.instance.w( @@ -474,9 +521,7 @@ class SolanaTokenWallet extends Wallet { // The actual ATA will be looked up via RPC in most cases return ownerPubkey; } catch (e) { - Logging.instance.w( - "$runtimeType _deriveAtaAddress error: $e", - ); + Logging.instance.w("$runtimeType _deriveAtaAddress error: $e"); rethrow; } } @@ -507,9 +552,7 @@ class SolanaTokenWallet extends Wallet { ); // Compile the message with the blockhash. - final compiledMessage = Message( - instructions: [instruction], - ).compile( + final compiledMessage = Message(instructions: [instruction]).compile( recentBlockhash: latestBlockhash.value.blockhash, feePayer: ownerPublicKey, ); @@ -527,9 +570,7 @@ class SolanaTokenWallet extends Wallet { return feeEstimate; } - Logging.instance.w( - "$runtimeType getFeeForMessage returned null", - ); + Logging.instance.w("$runtimeType getFeeForMessage returned null"); return null; } catch (e) { Logging.instance.w( @@ -554,10 +595,9 @@ class SolanaTokenWallet extends Wallet { while (true) { try { - final status = await rpcClient.getSignatureStatuses( - [signature], - searchTransactionHistory: true, - ); + final status = await rpcClient.getSignatureStatuses([ + signature, + ], searchTransactionHistory: true); if (status.value.isNotEmpty) { final txStatus = status.value.first; From 5b97739543462589fd2254b2a655692b5dcc96a5 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 11 Nov 2025 15:24:43 -0600 Subject: [PATCH 35/80] fix(spl): set initial sync status according to parent wallet --- lib/pages/token_view/sol_token_view.dart | 7 +++++-- .../wallet_view/desktop_sol_token_view.dart | 16 ++++++---------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/lib/pages/token_view/sol_token_view.dart b/lib/pages/token_view/sol_token_view.dart index b7812ee04..d206d2336 100644 --- a/lib/pages/token_view/sol_token_view.dart +++ b/lib/pages/token_view/sol_token_view.dart @@ -55,8 +55,11 @@ class _SolTokenViewState extends ConsumerState { @override void initState() { - // TODO: Integrate Solana token refresh status when available. - initialSyncStatus = WalletSyncStatus.synced; + // Get the initial sync status from the Solana wallet's refresh mutex. + final solanaWallet = ref.read(pSolanaWallet(widget.walletId)); + initialSyncStatus = solanaWallet?.refreshMutex.isLocked ?? false + ? WalletSyncStatus.syncing + : WalletSyncStatus.synced; // Initialize the Solana token wallet provider with mock data. // This sets up the pCurrentSolanaTokenWallet provider so that diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart index c5b1ab639..65aa72b9d 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart @@ -65,8 +65,11 @@ class _DesktopTokenViewState extends ConsumerState { WidgetsBinding.instance.addPostFrameCallback((_) { _initializeSolanaTokenWallet(); }); - // TODO: Integrate Solana token refresh status when available. - initialSyncStatus = WalletSyncStatus.synced; + // Get the initial sync status from the Solana wallet's refresh mutex. + final solanaWallet = ref.read(pSolanaWallet(widget.walletId)); + initialSyncStatus = solanaWallet?.refreshMutex.isLocked ?? false + ? WalletSyncStatus.syncing + : WalletSyncStatus.synced; super.initState(); } @@ -192,14 +195,7 @@ class _DesktopTokenViewState extends ConsumerState { DesktopWalletSummary( walletId: widget.walletId, isToken: true, - initialSyncStatus: - ref - .watch(pWallets) - .getWallet(widget.walletId) - .refreshMutex - .isLocked - ? WalletSyncStatus.syncing - : WalletSyncStatus.synced, + initialSyncStatus: initialSyncStatus, ), const Spacer(), DesktopWalletFeatures(walletId: widget.walletId), From a818a5fb1ee3dd0c51a6d5122e317373588fd1fd Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 11 Nov 2025 15:57:34 -0600 Subject: [PATCH 36/80] feat(spl): sol token price fetching --- .../sub_widgets/token_summary_sol.dart | 10 +- lib/services/price.dart | 92 +++++++++++++++++++ lib/services/price_service.dart | 19 ++++ 3 files changed, 118 insertions(+), 3 deletions(-) diff --git a/lib/pages/token_view/sub_widgets/token_summary_sol.dart b/lib/pages/token_view/sub_widgets/token_summary_sol.dart index af5764133..329050a35 100644 --- a/lib/pages/token_view/sub_widgets/token_summary_sol.dart +++ b/lib/pages/token_view/sub_widgets/token_summary_sol.dart @@ -15,6 +15,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; import '../../../providers/global/locale_provider.dart'; +import '../../../providers/global/price_provider.dart'; import '../../../providers/global/prefs_provider.dart'; import '../../../services/event_bus/events/global/wallet_sync_status_changed_event.dart'; import '../../../themes/stack_colors.dart'; @@ -80,9 +81,12 @@ class SolanaTokenSummary extends ConsumerWidget { Decimal? price; if (ref.watch(prefsChangeNotifierProvider.select((s) => s.externalCalls))) { - // TODO: Implement price fetching for Solana tokens. - // For now, prices are not fetched for Solana tokens. - price = null; + // Get the token price from the price service. + price = ref.watch( + priceAnd24hChangeNotifierProvider.select( + (value) => value.getTokenPrice(tokenMint)?.value, + ), + ); } return Stack( diff --git a/lib/services/price.dart b/lib/services/price.dart index a71c7e201..99abeba9d 100644 --- a/lib/services/price.dart +++ b/lib/services/price.dart @@ -290,4 +290,96 @@ class PriceAPI { return tokenPrices; } } + + /// Get prices and 24h change for Solana SPL tokens. + /// + /// Uses CoinGecko API to fetch prices for tokens by their Solana mint addresses. + /// Format: GET /api/v3/simple/token_price/solana?vs_currencies=usd&contract_addresses=mint1,mint2&include_24hr_change=true + Future> + getPricesAnd24hChangeForSolTokens({ + required Set contractAddresses, + required String baseCurrency, + }) async { + final Map tokenPrices = {}; + + if (AppConfig.coins.whereType().isEmpty || + contractAddresses.isEmpty) { + return tokenPrices; + } + + final externalCalls = Prefs.instance.externalCalls; + if ((!Util.isTestEnv && !externalCalls) || + !(await Prefs.instance.isExternalCallsSet())) { + Logging.instance.i("User does not want to use external calls"); + return tokenPrices; + } + + try { + // Build comma-separated list of mint addresses. + final mintsParam = contractAddresses.join(','); + final uri = Uri.parse( + "https://api.coingecko.com/api/v3/simple/token_price/solana" + "?vs_currencies=${baseCurrency.toLowerCase()}" + "&contract_addresses=$mintsParam" + "&include_24hr_change=true", + ); + + final coinGeckoResponse = await client.get( + url: uri, + headers: {'Content-Type': 'application/json'}, + proxyInfo: Prefs.instance.useTor + ? TorService.sharedInstance.getProxyInfo() + : null, + ); + + if (coinGeckoResponse.code == 200) { + try { + final coinGeckoData = jsonDecode(coinGeckoResponse.body) as Map; + + for (final mint in contractAddresses) { + final map = coinGeckoData[mint.toLowerCase()] as Map?; + if (map != null) { + try { + final price = Decimal.parse( + map[baseCurrency.toLowerCase()].toString(), + ); + final change24h = double.parse( + map["${baseCurrency.toLowerCase()}_24h_change"].toString(), + ); + + tokenPrices[mint.toLowerCase()] = ( + value: price, + change24h: change24h, + ); + } catch (e) { + // only log the error as we don't want to interrupt the rest of the loop + Logging.instance.w( + "getPricesAnd24hChangeForSolTokens($baseCurrency,$mint): Failed to parse price data: $e", + ); + } + } + } + } catch (e, s) { + // only log the error as we don't want to interrupt the rest of the loop + Logging.instance.w( + "getPricesAnd24hChangeForSolTokens($baseCurrency): Error parsing response: $e\n$s\nRESPONSE: ${coinGeckoResponse.body}", + ); + } + } else { + Logging.instance.w( + "getPricesAnd24hChangeForSolTokens($baseCurrency): HTTP ${coinGeckoResponse.code}", + ); + } + + return tokenPrices; + } catch (e, s) { + Logging.instance.e( + "getPricesAnd24hChangeForSolTokens($baseCurrency,$contractAddresses): ", + error: e, + stackTrace: s, + ); + // return previous cached values + return tokenPrices; + } + } } diff --git a/lib/services/price_service.dart b/lib/services/price_service.dart index 51fa4fab4..d10ebe973 100644 --- a/lib/services/price_service.dart +++ b/lib/services/price_service.dart @@ -25,6 +25,9 @@ class PriceService extends ChangeNotifier { Future> get tokenContractAddressesToCheck async => (await MainDB.instance.getEthContracts().addressProperty().findAll()) .toSet(); + Future> get solTokenContractAddressesToCheck async => + (await MainDB.instance.getSplTokens().addressProperty().findAll()) + .toSet(); final Duration updateInterval = const Duration(seconds: 60); Timer? _timer; @@ -73,6 +76,22 @@ class PriceService extends ChangeNotifier { } } + final _solTokenContractAddressesToCheck = await solTokenContractAddressesToCheck; + + if (_solTokenContractAddressesToCheck.isNotEmpty) { + final solTokenPriceMap = await _priceAPI.getPricesAnd24hChangeForSolTokens( + contractAddresses: _solTokenContractAddressesToCheck, + baseCurrency: baseTicker, + ); + + for (final map in solTokenPriceMap.entries) { + if (_cachedTokenPrices[map.key] != map.value) { + _cachedTokenPrices[map.key] = map.value; + shouldNotify = true; + } + } + } + if (shouldNotify) { notifyListeners(); } From ea14f973e1f26f1a5e7a1f8f52715b033bd04c5a Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 11 Nov 2025 15:58:59 -0600 Subject: [PATCH 37/80] fix(spl): graceful null check operator handling --- .../sub_widgets/desktop_sol_token_send.dart | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart index c35e346b3..95fd40dd8 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart @@ -691,7 +691,17 @@ class _DesktopSolTokenSendState extends ConsumerState { Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType"); - final tokenWallet = ref.watch(pCurrentSolanaTokenWallet)!; + final tokenWallet = ref.watch(pCurrentSolanaTokenWallet); + + // If wallet is not initialized, show a placeholder. + if (tokenWallet == null) { + return Center( + child: Text( + "Loading token data...", + style: STextStyles.subtitle500(context), + ), + ); + } return Column( crossAxisAlignment: CrossAxisAlignment.start, From 87b3e5c08c8198b8cf88bbeff5801b4bd48b1ee3 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 12 Nov 2025 14:39:08 -0600 Subject: [PATCH 38/80] fix(spl): prepare to replace novel token balance provider to be like eth 1/2, isar schema work next. --- .../sub_widgets/sol_token_select_item.dart | 21 +-- .../sub_widgets/token_summary_sol.dart | 172 ++++-------------- .../sub_widgets/desktop_sol_token_send.dart | 39 +--- .../sub_widgets/desktop_wallet_summary.dart | 15 +- .../solana/sol_token_balance_provider.dart | 135 +++----------- .../impl/sub_wallets/solana_token_wallet.dart | 26 +++ 6 files changed, 106 insertions(+), 302 deletions(-) diff --git a/lib/pages/token_view/sub_widgets/sol_token_select_item.dart b/lib/pages/token_view/sub_widgets/sol_token_select_item.dart index 24d3f1083..b7772a666 100644 --- a/lib/pages/token_view/sub_widgets/sol_token_select_item.dart +++ b/lib/pages/token_view/sub_widgets/sol_token_select_item.dart @@ -89,32 +89,19 @@ class _SolTokenSelectItemState extends ConsumerState { Expanded( child: Consumer( builder: (_, ref, __) { - // Fetch the balance. - final balanceAsync = ref.watch( + // Watch the balance from the database. + final balance = ref.watch( pSolanaTokenBalance( ( walletId: widget.walletId, tokenMint: widget.token.address, - fractionDigits: widget.token.decimals, ), ), ); // Format the balance. - String balanceString = "0.00 ${widget.token.symbol}"; - balanceAsync.when( - data: (balance) { - // Format the amount with the token symbol. - final decimalValue = balance.total.decimal.toStringAsFixed(widget.token.decimals); - balanceString = "$decimalValue ${widget.token.symbol}"; - }, - loading: () { - balanceString = "... ${widget.token.symbol}"; - }, - error: (error, stackTrace) { - balanceString = "0.00 ${widget.token.symbol}"; - }, - ); + final decimalValue = balance.total.decimal.toStringAsFixed(widget.token.decimals); + final balanceString = "$decimalValue ${widget.token.symbol}"; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, diff --git a/lib/pages/token_view/sub_widgets/token_summary_sol.dart b/lib/pages/token_view/sub_widgets/token_summary_sol.dart index 329050a35..e8de54b79 100644 --- a/lib/pages/token_view/sub_widgets/token_summary_sol.dart +++ b/lib/pages/token_view/sub_widgets/token_summary_sol.dart @@ -69,12 +69,12 @@ class SolanaTokenSummary extends ConsumerWidget { ); } - final balanceAsync = ref.watch( + // Watch the balance from the database provider. + final balance = ref.watch( pSolanaTokenBalance( ( walletId: walletId, tokenMint: tokenMint, - fractionDigits: tokenWallet.tokenDecimals, ), ), ); @@ -94,138 +94,36 @@ class SolanaTokenSummary extends ConsumerWidget { RoundedContainer( color: Theme.of(context).extension()!.tokenSummaryBG, padding: const EdgeInsets.all(24), - child: balanceAsync.when( - data: (balance) { - return Column( + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SvgPicture.asset( - Assets.svg.walletDesktop, - color: Theme.of( - context, - ).extension()!.tokenSummaryTextSecondary, - width: 12, - height: 12, - ), - const SizedBox(width: 6), - Text( - ref.watch(pWalletName(walletId)), - style: STextStyles.w500_12(context).copyWith( - color: Theme.of( - context, - ).extension()!.tokenSummaryTextSecondary, - ), - ), - ], - ), - const SizedBox(height: 6), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - balance.total.decimal.toStringAsFixed(tokenWallet.tokenDecimals), - style: STextStyles.pageTitleH1(context).copyWith( - color: Theme.of( - context, - ).extension()!.tokenSummaryTextPrimary, - ), - ), - const SizedBox(width: 10), - CoinTickerTag( - ticker: tokenWallet.tokenSymbol, - ), - ], - ), - if (price != null) const SizedBox(height: 6), - if (price != null) - Text( - "${(balance.total.decimal * price).toAmount(fractionDigits: 2).fiatString(locale: ref.watch(localeServiceChangeNotifierProvider.select((value) => value.locale)))} ${ref.watch(prefsChangeNotifierProvider.select((value) => value.currency))}", - style: STextStyles.subtitle500(context).copyWith( - color: Theme.of( - context, - ).extension()!.tokenSummaryTextPrimary, - ), - ), - const SizedBox(height: 20), - SolanaTokenWalletOptions( - walletId: walletId, - tokenMint: tokenMint, - ), - ], - ); - }, - loading: () { - return Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SvgPicture.asset( - Assets.svg.walletDesktop, - color: Theme.of( - context, - ).extension()!.tokenSummaryTextSecondary, - width: 12, - height: 12, - ), - const SizedBox(width: 6), - Text( - ref.watch(pWalletName(walletId)), - style: STextStyles.w500_12(context).copyWith( - color: Theme.of( - context, - ).extension()!.tokenSummaryTextSecondary, - ), - ), - ], + SvgPicture.asset( + Assets.svg.walletDesktop, + color: Theme.of( + context, + ).extension()!.tokenSummaryTextSecondary, + width: 12, + height: 12, ), - const SizedBox(height: 6), + const SizedBox(width: 6), Text( - "Loading balance...", - style: STextStyles.pageTitleH1(context).copyWith( + ref.watch(pWalletName(walletId)), + style: STextStyles.w500_12(context).copyWith( color: Theme.of( context, - ).extension()!.tokenSummaryTextPrimary, + ).extension()!.tokenSummaryTextSecondary, ), ), - const SizedBox(height: 20), - SolanaTokenWalletOptions( - walletId: walletId, - tokenMint: tokenMint, - ), ], - ); - }, - error: (error, stackTrace) { - return Column( + ), + const SizedBox(height: 6), + Row( + mainAxisAlignment: MainAxisAlignment.center, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SvgPicture.asset( - Assets.svg.walletDesktop, - color: Theme.of( - context, - ).extension()!.tokenSummaryTextSecondary, - width: 12, - height: 12, - ), - const SizedBox(width: 6), - Text( - ref.watch(pWalletName(walletId)), - style: STextStyles.w500_12(context).copyWith( - color: Theme.of( - context, - ).extension()!.tokenSummaryTextSecondary, - ), - ), - ], - ), - const SizedBox(height: 6), Text( - "0.00", + balance.total.decimal.toStringAsFixed(tokenWallet.tokenDecimals), style: STextStyles.pageTitleH1(context).copyWith( color: Theme.of( context, @@ -236,14 +134,24 @@ class SolanaTokenSummary extends ConsumerWidget { CoinTickerTag( ticker: tokenWallet.tokenSymbol, ), - const SizedBox(height: 20), - SolanaTokenWalletOptions( - walletId: walletId, - tokenMint: tokenMint, - ), ], - ); - }, + ), + if (price != null) const SizedBox(height: 6), + if (price != null) + Text( + "${(balance.total.decimal * price).toAmount(fractionDigits: 2).fiatString(locale: ref.watch(localeServiceChangeNotifierProvider.select((value) => value.locale)))} ${ref.watch(prefsChangeNotifierProvider.select((value) => value.currency))}", + style: STextStyles.subtitle500(context).copyWith( + color: Theme.of( + context, + ).extension()!.tokenSummaryTextPrimary, + ), + ), + const SizedBox(height: 20), + SolanaTokenWalletOptions( + walletId: walletId, + tokenMint: tokenMint, + ), + ], ), ), Positioned( @@ -405,4 +313,4 @@ class TokenOptionsButton extends StatelessWidget { ], ); } -} \ No newline at end of file +} diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart index 95fd40dd8..cc20e75f0 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart @@ -103,27 +103,15 @@ class _DesktopSolTokenSendState extends ConsumerState { final Amount amount = _amountToSend!; - // Get the current balance (already cached from UI display). - final balanceAsyncValue = ref.read( + // Get the current balance from the database. + final balance = ref.read( pSolanaTokenBalance(( walletId: walletId, tokenMint: tokenWallet.tokenMint, - fractionDigits: tokenWallet.tokenDecimals, )), ); - late Amount availableBalance; - balanceAsyncValue.when( - data: (balance) { - availableBalance = balance.spendable; - }, - error: (error, stackTrace) { - throw Exception('Failed to fetch balance: $error'); - }, - loading: () { - throw Exception('Balance is still loading'); - }, - ); + final availableBalance = balance.spendable; // confirm send all if (amount == availableBalance) { @@ -610,28 +598,17 @@ class _DesktopSolTokenSendState extends ConsumerState { Future sendAllTapped() async { final tokenWallet = ref.read(pCurrentSolanaTokenWallet)!; - final balanceAsyncValue = ref.read( + final balance = ref.read( pSolanaTokenBalance(( walletId: walletId, tokenMint: tokenWallet.tokenMint, - fractionDigits: tokenWallet.tokenDecimals, )), ); - balanceAsyncValue.when( - data: (balance) { - cryptoAmountController.text = balance - .spendable - .decimal - .toStringAsFixed(tokenWallet.tokenDecimals); - }, - error: (error, stackTrace) { - Logging.instance.e('Failed to fetch balance for send all: $error'); - }, - loading: () { - // Should not happen with read. - }, - ); + cryptoAmountController.text = balance + .spendable + .decimal + .toStringAsFixed(tokenWallet.tokenDecimals); } @override diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_summary.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_summary.dart index 688044f3e..9071a9ef5 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_summary.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_summary.dart @@ -153,24 +153,13 @@ class _WDesktopWalletSummaryState extends ConsumerState { )), ); } else if (widget.isToken && solanaTokenWallet != null) { - // Solana token balance - handle async value. - final balanceAsync = ref.watch( + // Watch Solana token balance from db. + balance = ref.watch( pSolanaTokenBalance(( walletId: walletId, tokenMint: (solanaTokenWallet as dynamic).tokenMint, - fractionDigits: (solanaTokenWallet as dynamic).tokenDecimals, )), ); - // Extract the balance from AsyncValue, defaulting to zero if not loaded. - final decimals = (solanaTokenWallet as dynamic).tokenDecimals as int; - balance = - balanceAsync.whenData((b) => b).value ?? - Balance( - total: Amount.zeroWith(fractionDigits: decimals), - spendable: Amount.zeroWith(fractionDigits: decimals), - blockedTotal: Amount.zeroWith(fractionDigits: decimals), - pendingSpendable: Amount.zeroWith(fractionDigits: decimals), - ); } else { // Regular wallet balance. balance = ref.watch(pWalletBalance(walletId)); diff --git a/lib/wallets/isar/providers/solana/sol_token_balance_provider.dart b/lib/wallets/isar/providers/solana/sol_token_balance_provider.dart index ad36db897..f2c419963 100644 --- a/lib/wallets/isar/providers/solana/sol_token_balance_provider.dart +++ b/lib/wallets/isar/providers/solana/sol_token_balance_provider.dart @@ -1,119 +1,36 @@ -import 'package:decimal/decimal.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../models/balance.dart'; -import '../../../../providers/global/wallets_provider.dart'; -import '../../../../services/solana/solana_token_api.dart'; import '../../../../utilities/amount/amount.dart'; -import '../../../../wallets/wallet/impl/solana_wallet.dart'; -/// Provider family for Solana token balance. +/// Provider for Solana token balance. /// -/// Fetches the token balance from the Solana blockchain via RPC. +/// NOTE: This is a temporary implementation that returns zero balance. +/// TODO: Integrate with Isar database persistence once SolanaTokenWalletInfo +/// model is properly registered in the Isar schema. /// -/// Example usage in UI: +/// The intent is to follow the Ethereum token balance pattern: +/// - pSolanaTokenWalletInfo: Watches SolanaTokenWalletInfo from database +/// - pSolanaTokenBalance: Returns cached balance from SolanaTokenWalletInfo +/// +/// This ensures the UI reactively updates when balances are persisted to the +/// database by SolanaTokenWallet.updateBalance(). +/// +/// Example usage: /// final balance = ref.watch( -/// pSolanaTokenBalance((walletId: 'wallet1', tokenMint: 'EPjFWaJUwYUoRwzwkH4H8gNB7zHW9tLT6NCKB8S4yh6h', fractionDigits: 6)) +/// pSolanaTokenBalance((walletId: 'wallet1', tokenMint: 'EPjFWaJUwYUoRwzwkH4H8gNB7zHW9tLT6NCKB8S4yh6h')) /// ); -final pSolanaTokenBalance = FutureProvider.family< - Balance, - ({String walletId, String tokenMint, int fractionDigits})>((ref, params) async { - // Get the wallet from the wallets provider. - final wallets = ref.watch(pWallets); - final wallet = wallets.getWallet(params.walletId); - - if (wallet == null || wallet is! SolanaWallet) { - // Return zero balance if wallet not found or not Solana. - return Balance( - total: Amount.zeroWith(fractionDigits: params.fractionDigits), - spendable: Amount.zeroWith(fractionDigits: params.fractionDigits), - blockedTotal: Amount.zeroWith(fractionDigits: params.fractionDigits), - pendingSpendable: Amount.zeroWith(fractionDigits: params.fractionDigits), - ); - } - - try { - // Initialize the SolanaTokenAPI with the RPC client. - final tokenApi = SolanaTokenAPI(); - final rpcClient = wallet.getRpcClient(); - - if (rpcClient == null) { - // Return zero balance if RPC client not available. - return Balance( - total: Amount.zeroWith(fractionDigits: params.fractionDigits), - spendable: Amount.zeroWith(fractionDigits: params.fractionDigits), - blockedTotal: Amount.zeroWith(fractionDigits: params.fractionDigits), - pendingSpendable: Amount.zeroWith(fractionDigits: params.fractionDigits), - ); - } - - tokenApi.initializeRpcClient(rpcClient); - - // Get the wallet address. - final addressObj = await wallet.getCurrentReceivingAddress(); - if (addressObj == null) { - // Return zero balance if address not found. - return Balance( - total: Amount.zeroWith(fractionDigits: params.fractionDigits), - spendable: Amount.zeroWith(fractionDigits: params.fractionDigits), - blockedTotal: Amount.zeroWith(fractionDigits: params.fractionDigits), - pendingSpendable: Amount.zeroWith(fractionDigits: params.fractionDigits), - ); - } - - final walletAddress = addressObj.value; - - // Get token accounts for this wallet and mint. - final accountsResponse = await tokenApi.getTokenAccountsByOwner( - walletAddress, - mint: params.tokenMint, - ); - - if (accountsResponse.isError || accountsResponse.value == null || accountsResponse.value!.isEmpty) { - // Return zero balance if no token accounts found. - return Balance( - total: Amount.zeroWith(fractionDigits: params.fractionDigits), - spendable: Amount.zeroWith(fractionDigits: params.fractionDigits), - blockedTotal: Amount.zeroWith(fractionDigits: params.fractionDigits), - pendingSpendable: Amount.zeroWith(fractionDigits: params.fractionDigits), - ); - } - - // Get the balance of the first token account. - final tokenAccountAddress = accountsResponse.value!.first; - final balanceResponse = await tokenApi.getTokenAccountBalance(tokenAccountAddress); - - if (balanceResponse.isError || balanceResponse.value == null) { - // Return zero balance if balance fetch failed. - return Balance( - total: Amount.zeroWith(fractionDigits: params.fractionDigits), - spendable: Amount.zeroWith(fractionDigits: params.fractionDigits), - blockedTotal: Amount.zeroWith(fractionDigits: params.fractionDigits), - pendingSpendable: Amount.zeroWith(fractionDigits: params.fractionDigits), - ); - } - - // Convert the BigInt balance to an Amount with the token's fractional digits. - final balanceBigInt = balanceResponse.value!; - final balanceAmount = Amount( - rawValue: balanceBigInt, - fractionDigits: params.fractionDigits, - ); - - return Balance( - total: balanceAmount, - spendable: balanceAmount, - blockedTotal: Amount.zeroWith(fractionDigits: params.fractionDigits), - pendingSpendable: Amount.zeroWith(fractionDigits: params.fractionDigits), - ); - } catch (e) { - // Return zero balance if any error occurs. - print('Error fetching Solana token balance: $e'); - return Balance( - total: Amount.zeroWith(fractionDigits: params.fractionDigits), - spendable: Amount.zeroWith(fractionDigits: params.fractionDigits), - blockedTotal: Amount.zeroWith(fractionDigits: params.fractionDigits), - pendingSpendable: Amount.zeroWith(fractionDigits: params.fractionDigits), - ); - } +final pSolanaTokenBalance = Provider.family< + Balance, + ({String walletId, String tokenMint}) +>((ref, data) { + // TODO: Replace with database-backed implementation once Isar schema includes + // SolanaTokenWalletInfo. For now, return zero balance to prevent crashes. + // This ensures the UI doesn't break while the database layer is being prepared. + return Balance( + total: Amount.zeroWith(fractionDigits: 6), + spendable: Amount.zeroWith(fractionDigits: 6), + blockedTotal: Amount.zeroWith(fractionDigits: 6), + pendingSpendable: Amount.zeroWith(fractionDigits: 6), + ); }); diff --git a/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart b/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart index 7e77780d3..a66af6d18 100644 --- a/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart +++ b/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart @@ -13,6 +13,7 @@ import 'package:isar_community/isar.dart'; import 'package:solana/dto.dart'; import 'package:solana/solana.dart' hide Wallet; +import '../../../../models/balance.dart'; import '../../../../models/paymint/fee_object_model.dart'; import '../../../../services/solana/solana_token_api.dart'; import '../../../../utilities/amount/amount.dart'; @@ -356,6 +357,31 @@ class SolanaTokenWallet extends Wallet { Logging.instance.i( "$runtimeType updateBalance: New balance = ${balanceResponse.value} (${balanceResponse.value! / BigInt.from(10).pow(tokenDecimals)} ${tokenSymbol})", ); + + // TODO: Persist balance to SolanaTokenWalletInfo in Isar database. + // Once SolanaTokenWalletInfo is added to the Isar schema, follow the + // Ethereum pattern from eth_token_wallet.dart:316-330: + // + // final info = await mainDB.isar.solanaTokenWalletInfo + // .where() + // .walletIdTokenAddressEqualTo(walletId, tokenMint) + // .findFirst(); + // + // if (info != null) { + // final balanceAmount = Amount( + // rawValue: balanceResponse.value!, + // fractionDigits: tokenDecimals, + // ); + // + // final balance = Balance( + // total: balanceAmount, + // spendable: balanceAmount, + // blockedTotal: Amount(rawValue: BigInt.zero, fractionDigits: tokenDecimals), + // pendingSpendable: Amount(rawValue: BigInt.zero, fractionDigits: tokenDecimals), + // ); + // + // await info.updateCachedBalance(balance, isar: mainDB.isar); + // } } } catch (e, s) { Logging.instance.e( From a8906cf798ad78732f38714408f8d9c57ebd97cf Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 12 Nov 2025 16:13:19 -0600 Subject: [PATCH 39/80] fix(spl): replace novel token balance provider to follow eth's example --- lib/db/isar/main_db.dart | 1 + lib/models/isar/models/isar_models.dart | 1 + .../sub_widgets/wallet_refresh_button.dart | 22 +++- .../isar/models/wallet_solana_token_info.dart | 88 ++++++++++++++ .../solana/sol_token_balance_provider.dart | 108 +++++++++++++++--- .../impl/sub_wallets/solana_token_wallet.dart | 90 ++++++++++----- 6 files changed, 261 insertions(+), 49 deletions(-) create mode 100644 lib/wallets/isar/models/wallet_solana_token_info.dart diff --git a/lib/db/isar/main_db.dart b/lib/db/isar/main_db.dart index 61b75c9cc..424572990 100644 --- a/lib/db/isar/main_db.dart +++ b/lib/db/isar/main_db.dart @@ -70,6 +70,7 @@ class MainDB { WalletInfoMetaSchema, TokenWalletInfoSchema, FrostWalletInfoSchema, + WalletSolanaTokenInfoSchema, ], directory: (await StackFileSystem.applicationIsarDirectory()).path, // inspector: kDebugMode, diff --git a/lib/models/isar/models/isar_models.dart b/lib/models/isar/models/isar_models.dart index d164ec62b..eb61a82b9 100644 --- a/lib/models/isar/models/isar_models.dart +++ b/lib/models/isar/models/isar_models.dart @@ -18,3 +18,4 @@ export 'ethereum/eth_contract.dart'; export 'log.dart'; export 'solana/spl_token.dart'; export 'transaction_note.dart'; +export '../../../wallets/isar/models/wallet_solana_token_info.dart'; diff --git a/lib/pages/wallet_view/sub_widgets/wallet_refresh_button.dart b/lib/pages/wallet_view/sub_widgets/wallet_refresh_button.dart index 6e98f4a3f..e75a063d5 100644 --- a/lib/pages/wallet_view/sub_widgets/wallet_refresh_button.dart +++ b/lib/pages/wallet_view/sub_widgets/wallet_refresh_button.dart @@ -21,6 +21,7 @@ import '../../../themes/stack_colors.dart'; import '../../../utilities/constants.dart'; import '../../../utilities/util.dart'; import '../../../wallets/isar/providers/eth/current_token_wallet_provider.dart'; +import '../../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; import '../../../widgets/animated_widgets/rotating_arrows.dart'; /// [eventBus] should only be set during testing @@ -112,13 +113,24 @@ class _RefreshButtonState extends ConsumerState { splashColor: Theme.of(context).extension()!.highlight, onPressed: () { if (widget.tokenContractAddress == null) { - final wallet = ref.read(pWallets).getWallet(widget.walletId); - final isRefreshing = wallet.refreshMutex.isLocked; - if (!isRefreshing) { - _spinController.repeat?.call(); - wallet.refresh().then((_) => _spinController.stop?.call()); + // Solana token - check if there's a current Solana token wallet. + final solanaTokenWallet = ref.read(pCurrentSolanaTokenWallet); + if (solanaTokenWallet != null) { + if (!solanaTokenWallet.refreshMutex.isLocked) { + _spinController.repeat?.call(); + solanaTokenWallet.refresh().then((_) => _spinController.stop?.call()); + } + } else { + // Fall back to refreshing the parent Solana wallet. + final wallet = ref.read(pWallets).getWallet(widget.walletId); + final isRefreshing = wallet.refreshMutex.isLocked; + if (!isRefreshing) { + _spinController.repeat?.call(); + wallet.refresh().then((_) => _spinController.stop?.call()); + } } } else { + // Ethereum token. if (!ref.read(pCurrentTokenWallet)!.refreshMutex.isLocked) { ref.read(pCurrentTokenWallet)!.refresh(); } diff --git a/lib/wallets/isar/models/wallet_solana_token_info.dart b/lib/wallets/isar/models/wallet_solana_token_info.dart new file mode 100644 index 000000000..a80e9893e --- /dev/null +++ b/lib/wallets/isar/models/wallet_solana_token_info.dart @@ -0,0 +1,88 @@ +/* + * 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:isar_community/isar.dart'; + +import '../../../models/balance.dart'; +import '../../../models/isar/models/isar_models.dart'; +import '../../../utilities/amount/amount.dart'; +import '../isar_id_interface.dart'; + +part 'wallet_solana_token_info.g.dart'; + +@Collection(accessor: "walletSolanaTokenInfo", inheritance: false) +class WalletSolanaTokenInfo implements IsarId { + @override + Id id = Isar.autoIncrement; + + @Index( + unique: true, + replace: false, + composite: [CompositeIndex("tokenAddress")], + ) + final String walletId; + + final String tokenAddress; // Mint address. + + final int tokenFractionDigits; + + final String? cachedBalanceJsonString; + + WalletSolanaTokenInfo({ + required this.walletId, + required this.tokenAddress, + required this.tokenFractionDigits, + this.cachedBalanceJsonString, + }); + + SplToken getToken(Isar isar) => + isar.splTokens.where().addressEqualTo(tokenAddress).findFirstSync()!; + + // Token balance cache. + Balance getCachedBalance() { + if (cachedBalanceJsonString == null) { + return Balance( + total: Amount.zeroWith(fractionDigits: tokenFractionDigits), + spendable: Amount.zeroWith(fractionDigits: tokenFractionDigits), + blockedTotal: Amount.zeroWith(fractionDigits: tokenFractionDigits), + pendingSpendable: Amount.zeroWith(fractionDigits: tokenFractionDigits), + ); + } + return Balance.fromJson(cachedBalanceJsonString!, tokenFractionDigits); + } + + Future updateCachedBalance( + Balance balance, { + required Isar isar, + }) async { + // Ensure we are updating using the latest entry of this in the db. + final thisEntry = + await isar.walletSolanaTokenInfo + .where() + .walletIdTokenAddressEqualTo(walletId, tokenAddress) + .findFirst(); + if (thisEntry == null) { + throw Exception( + "Attempted to update cached token balance before object was saved in db", + ); + } else { + await isar.writeTxn(() async { + await isar.walletSolanaTokenInfo.delete(thisEntry.id); + await isar.walletSolanaTokenInfo.put( + WalletSolanaTokenInfo( + walletId: walletId, + tokenAddress: tokenAddress, + tokenFractionDigits: tokenFractionDigits, + cachedBalanceJsonString: balance.toJsonIgnoreCoin(), + )..id = thisEntry.id, + ); + }); + } + } +} diff --git a/lib/wallets/isar/providers/solana/sol_token_balance_provider.dart b/lib/wallets/isar/providers/solana/sol_token_balance_provider.dart index f2c419963..831c50836 100644 --- a/lib/wallets/isar/providers/solana/sol_token_balance_provider.dart +++ b/lib/wallets/isar/providers/solana/sol_token_balance_provider.dart @@ -1,20 +1,90 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:isar_community/isar.dart'; import '../../../../models/balance.dart'; -import '../../../../utilities/amount/amount.dart'; +import '../../../../models/isar/models/isar_models.dart'; +import '../../../../providers/db/main_db_provider.dart'; +import '../../../../utilities/logger.dart'; +import '../util/watcher.dart'; -/// Provider for Solana token balance. +/// Provider family for Solana token wallet info. /// -/// NOTE: This is a temporary implementation that returns zero balance. -/// TODO: Integrate with Isar database persistence once SolanaTokenWalletInfo -/// model is properly registered in the Isar schema. +/// Watches the Isar database for changes to WalletSolanaTokenInfo. +/// Mirrors the pattern used for Ethereum token balances (TokenWalletInfo). /// -/// The intent is to follow the Ethereum token balance pattern: -/// - pSolanaTokenWalletInfo: Watches SolanaTokenWalletInfo from database -/// - pSolanaTokenBalance: Returns cached balance from SolanaTokenWalletInfo +/// Example usage: +/// final info = ref.watch( +/// pSolanaTokenWalletInfo((walletId: 'wallet1', tokenMint: 'EPjFWaJUwYUoRwzwkH4H8gNB7zHW9tLT6NCKB8S4yh6h')) +/// ); +final _wstwiProvider = ChangeNotifierProvider.family< + Watcher, + ({String walletId, String tokenMint}) +>((ref, data) { + final isar = ref.watch(mainDBProvider).isar; + + final collection = isar.walletSolanaTokenInfo; + + Logging.instance.i( + "pSolanaTokenBalance: Looking up WalletSolanaTokenInfo for walletId=${data.walletId}, tokenMint=${data.tokenMint}", + ); + + WalletSolanaTokenInfo? initial = collection + .where() + .walletIdTokenAddressEqualTo(data.walletId, data.tokenMint) + .findFirstSync(); + + if (initial == null) { + Logging.instance.i( + "pSolanaTokenBalance: Creating new WalletSolanaTokenInfo entry", + ); + + // Create initial entry if not found. + final splToken = + isar.splTokens.getByAddressSync(data.tokenMint); + + initial = WalletSolanaTokenInfo( + walletId: data.walletId, + tokenAddress: data.tokenMint, + tokenFractionDigits: splToken?.decimals ?? 6, + ); + + isar.writeTxnSync(() => isar.walletSolanaTokenInfo.putSync(initial!)); + + // After insert, fetch the object again to get the assigned ID. + initial = collection + .where() + .walletIdTokenAddressEqualTo(data.walletId, data.tokenMint) + .findFirstSync()!; + + Logging.instance.i( + "pSolanaTokenBalance: Created entry with ID=${initial.id}, balance=${initial.getCachedBalance().total}", + ); + } else { + Logging.instance.i( + "pSolanaTokenBalance: Found existing entry with ID=${initial.id}, cachedBalance=${initial.getCachedBalance().total}", + ); + } + + final watcher = Watcher(initial, collection: collection); + + ref.onDispose(() => watcher.dispose()); + + return watcher; +}); + +/// Provider for Solana token wallet info from the database. +final pSolanaTokenWalletInfo = Provider.family< + WalletSolanaTokenInfo, + ({String walletId, String tokenMint}) +>((ref, data) { + return ref.watch(_wstwiProvider(data).select((value) => value.value)) + as WalletSolanaTokenInfo; +}); + +/// Provider for Solana token balance from the database. /// -/// This ensures the UI reactively updates when balances are persisted to the -/// database by SolanaTokenWallet.updateBalance(). +/// This provider watches the Isar database and will automatically update +/// the UI whenever the balance changes in the database. /// /// Example usage: /// final balance = ref.watch( @@ -24,13 +94,15 @@ final pSolanaTokenBalance = Provider.family< Balance, ({String walletId, String tokenMint}) >((ref, data) { - // TODO: Replace with database-backed implementation once Isar schema includes - // SolanaTokenWalletInfo. For now, return zero balance to prevent crashes. - // This ensures the UI doesn't break while the database layer is being prepared. - return Balance( - total: Amount.zeroWith(fractionDigits: 6), - spendable: Amount.zeroWith(fractionDigits: 6), - blockedTotal: Amount.zeroWith(fractionDigits: 6), - pendingSpendable: Amount.zeroWith(fractionDigits: 6), + final balance = ref.watch( + _wstwiProvider(data).select( + (value) => (value.value as WalletSolanaTokenInfo).getCachedBalance(), + ), ); + + Logging.instance.i( + "pSolanaTokenBalance: Returning balance=${balance.total} for walletId=${data.walletId}, tokenMint=${data.tokenMint}", + ); + + return balance; }); diff --git a/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart b/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart index a66af6d18..2de296b82 100644 --- a/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart +++ b/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart @@ -13,12 +13,14 @@ import 'package:isar_community/isar.dart'; import 'package:solana/dto.dart'; import 'package:solana/solana.dart' hide Wallet; +import '../../../../db/isar/main_db.dart'; import '../../../../models/balance.dart'; import '../../../../models/paymint/fee_object_model.dart'; import '../../../../services/solana/solana_token_api.dart'; import '../../../../utilities/amount/amount.dart'; import '../../../../utilities/logger.dart'; import '../../../crypto_currency/crypto_currency.dart'; +import '../../../isar/models/wallet_solana_token_info.dart'; import '../../../models/tx_data.dart'; import '../../wallet.dart'; import '../solana_wallet.dart'; @@ -47,6 +49,15 @@ class SolanaTokenWallet extends Wallet { final String tokenSymbol; final int tokenDecimals; + /// Override walletId to delegate to parent wallet + @override + String get walletId => parentSolanaWallet.walletId; + + /// Override mainDB to delegate to parent wallet + /// (SolanaTokenWallet shares the same database as its parent) + @override + MainDB get mainDB => parentSolanaWallet.mainDB; + // ========================================================================= // Abstract method implementations // ========================================================================= @@ -312,6 +323,10 @@ class SolanaTokenWallet extends Wallet { @override Future updateBalance() async { try { + Logging.instance.i( + "$runtimeType updateBalance: Starting balance update for tokenMint=$tokenMint", + ); + final rpcClient = parentSolanaWallet.getRpcClient(); if (rpcClient == null) { Logging.instance.w( @@ -323,6 +338,10 @@ class SolanaTokenWallet extends Wallet { final keyPair = await parentSolanaWallet.getKeyPair(); final walletAddress = keyPair.address; + Logging.instance.i( + "$runtimeType updateBalance: Wallet address = $walletAddress", + ); + // Get sender's token account. final senderTokenAccount = await _findTokenAccount( ownerAddress: walletAddress, @@ -337,6 +356,10 @@ class SolanaTokenWallet extends Wallet { return; } + Logging.instance.i( + "$runtimeType updateBalance: Found token account = $senderTokenAccount", + ); + // Fetch the token balance. final tokenApi = SolanaTokenAPI(); tokenApi.initializeRpcClient(rpcClient); @@ -358,30 +381,41 @@ class SolanaTokenWallet extends Wallet { "$runtimeType updateBalance: New balance = ${balanceResponse.value} (${balanceResponse.value! / BigInt.from(10).pow(tokenDecimals)} ${tokenSymbol})", ); - // TODO: Persist balance to SolanaTokenWalletInfo in Isar database. - // Once SolanaTokenWalletInfo is added to the Isar schema, follow the - // Ethereum pattern from eth_token_wallet.dart:316-330: - // - // final info = await mainDB.isar.solanaTokenWalletInfo - // .where() - // .walletIdTokenAddressEqualTo(walletId, tokenMint) - // .findFirst(); - // - // if (info != null) { - // final balanceAmount = Amount( - // rawValue: balanceResponse.value!, - // fractionDigits: tokenDecimals, - // ); - // - // final balance = Balance( - // total: balanceAmount, - // spendable: balanceAmount, - // blockedTotal: Amount(rawValue: BigInt.zero, fractionDigits: tokenDecimals), - // pendingSpendable: Amount(rawValue: BigInt.zero, fractionDigits: tokenDecimals), - // ); - // - // await info.updateCachedBalance(balance, isar: mainDB.isar); - // } + // Persist balance to WalletSolanaTokenInfo in Isar database. + Logging.instance.i( + "$runtimeType updateBalance: Looking up WalletSolanaTokenInfo for walletId=$walletId, tokenMint=$tokenMint", + ); + + final info = await mainDB.isar.walletSolanaTokenInfo + .where() + .walletIdTokenAddressEqualTo(walletId, tokenMint) + .findFirst(); + + if (info != null) { + Logging.instance.i( + "$runtimeType updateBalance: Found WalletSolanaTokenInfo with ID=${info.id}, updating cached balance", + ); + + final balanceAmount = Amount( + rawValue: balanceResponse.value!, + fractionDigits: tokenDecimals, + ); + + final balance = Balance( + total: balanceAmount, + spendable: balanceAmount, + blockedTotal: Amount( + rawValue: BigInt.zero, + fractionDigits: tokenDecimals, + ), + pendingSpendable: Amount( + rawValue: BigInt.zero, + fractionDigits: tokenDecimals, + ), + ); + + await info.updateCachedBalance(balance, isar: mainDB.isar); + } } } catch (e, s) { Logging.instance.e( @@ -405,9 +439,13 @@ class SolanaTokenWallet extends Wallet { @override Future refresh() async { - // Token wallets are temporary objects created for transactions. - // They don't need to refresh themselves. Refresh the parent wallet instead. + Logging.instance.i( + "$runtimeType refresh: Starting refresh for tokenMint=$tokenMint", + ); + // Refresh both the parent wallet and token balance. + // This ensures the cached token balance in the database is updated. await parentSolanaWallet.refresh(); + await updateBalance(); } @override From cc4ecbb0ef3a0efb61a21739d3d66cb3ad33440e Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 12 Nov 2025 16:46:06 -0600 Subject: [PATCH 40/80] refactor(spl): don't create separate Amount objs --- lib/wallets/isar/models/token_wallet_info.dart | 12 ++++++++---- .../isar/models/wallet_solana_token_info.dart | 12 ++++++++---- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/lib/wallets/isar/models/token_wallet_info.dart b/lib/wallets/isar/models/token_wallet_info.dart index 842d04bf0..767c5a2f9 100644 --- a/lib/wallets/isar/models/token_wallet_info.dart +++ b/lib/wallets/isar/models/token_wallet_info.dart @@ -38,11 +38,15 @@ class TokenWalletInfo implements IsarId { // token balance cache Balance getCachedBalance() { if (cachedBalanceJsonString == null) { + final amount = Amount( + rawValue: BigInt.zero, + fractionDigits: tokenFractionDigits, + ); return Balance( - total: Amount.zeroWith(fractionDigits: tokenFractionDigits), - spendable: Amount.zeroWith(fractionDigits: tokenFractionDigits), - blockedTotal: Amount.zeroWith(fractionDigits: tokenFractionDigits), - pendingSpendable: Amount.zeroWith(fractionDigits: tokenFractionDigits), + total: amount, + spendable: amount, + blockedTotal: amount, + pendingSpendable: amount, ); } return Balance.fromJson(cachedBalanceJsonString!, tokenFractionDigits); diff --git a/lib/wallets/isar/models/wallet_solana_token_info.dart b/lib/wallets/isar/models/wallet_solana_token_info.dart index a80e9893e..16d45b168 100644 --- a/lib/wallets/isar/models/wallet_solana_token_info.dart +++ b/lib/wallets/isar/models/wallet_solana_token_info.dart @@ -47,11 +47,15 @@ class WalletSolanaTokenInfo implements IsarId { // Token balance cache. Balance getCachedBalance() { if (cachedBalanceJsonString == null) { + final amount = Amount( + rawValue: BigInt.zero, + fractionDigits: tokenFractionDigits, + ); return Balance( - total: Amount.zeroWith(fractionDigits: tokenFractionDigits), - spendable: Amount.zeroWith(fractionDigits: tokenFractionDigits), - blockedTotal: Amount.zeroWith(fractionDigits: tokenFractionDigits), - pendingSpendable: Amount.zeroWith(fractionDigits: tokenFractionDigits), + total: amount, + spendable: amount, + blockedTotal: amount, + pendingSpendable: amount, ); } return Balance.fromJson(cachedBalanceJsonString!, tokenFractionDigits); From 4a860f0498ce979b4ea3f40069c6c9def9c935da Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 12 Nov 2025 16:59:38 -0600 Subject: [PATCH 41/80] refactor(spl): remove unneeded sol wallet token address provider --- .../edit_wallet_tokens_view.dart | 11 ++------- lib/pages/token_view/my_tokens_view.dart | 12 ++++------ .../sol_wallet_token_addresses_provider.dart | 23 ------------------- .../isar/providers/wallet_info_provider.dart | 18 +++++++++++---- 4 files changed, 20 insertions(+), 44 deletions(-) delete mode 100644 lib/wallets/isar/providers/solana/sol_wallet_token_addresses_provider.dart 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 1602aa467..e86182bc2 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 @@ -31,7 +31,6 @@ 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/isar/providers/solana/sol_wallet_token_addresses_provider.dart'; import '../../../wallets/wallet/impl/ethereum_wallet.dart'; import '../../../wallets/wallet/impl/solana_wallet.dart'; import '../../../widgets/background.dart'; @@ -376,14 +375,8 @@ class _EditWalletTokensViewState extends ConsumerState { tokenEntities.addAll(contracts.map((e) => AddTokenListElementData(e))); } - // Get the appropriate token addresses based on wallet type. - List walletContracts = []; - - if (wallet is SolanaWallet) { - walletContracts = ref.read(pSolanaWalletTokenAddresses(widget.walletId)); - } else { - walletContracts = ref.read(pWalletTokenAddresses(widget.walletId)); - } + // Get token addresses. + final walletContracts = ref.read(pWalletTokenAddresses(widget.walletId)); final shouldMarkAsSelectedContracts = [ ...walletContracts, diff --git a/lib/pages/token_view/my_tokens_view.dart b/lib/pages/token_view/my_tokens_view.dart index ad4fd8b6f..7e1c621c0 100644 --- a/lib/pages/token_view/my_tokens_view.dart +++ b/lib/pages/token_view/my_tokens_view.dart @@ -20,7 +20,6 @@ import '../../utilities/assets.dart'; import '../../utilities/constants.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; -import '../../wallets/isar/providers/solana/sol_wallet_token_addresses_provider.dart'; import '../../wallets/isar/providers/wallet_info_provider.dart'; import '../../wallets/wallet/impl/solana_wallet.dart'; import '../../widgets/background.dart'; @@ -224,21 +223,20 @@ class _MyTokensViewState extends ConsumerState { child: Builder( builder: (context) { final wallet = ref.watch(pWallets).getWallet(widget.walletId); + final tokenAddresses = ref.watch( + pWalletTokenAddresses(widget.walletId), + ); if (wallet is SolanaWallet) { return SolanaTokensList( walletId: widget.walletId, searchTerm: _searchString, - tokenMints: ref.watch( - pSolanaWalletTokenAddresses(widget.walletId), - ), + tokenMints: tokenAddresses, ); } else { return MyTokensList( walletId: widget.walletId, searchTerm: _searchString, - tokenContracts: ref.watch( - pWalletTokenAddresses(widget.walletId), - ), + tokenContracts: tokenAddresses, ); } }, diff --git a/lib/wallets/isar/providers/solana/sol_wallet_token_addresses_provider.dart b/lib/wallets/isar/providers/solana/sol_wallet_token_addresses_provider.dart deleted file mode 100644 index defccf4c3..000000000 --- a/lib/wallets/isar/providers/solana/sol_wallet_token_addresses_provider.dart +++ /dev/null @@ -1,23 +0,0 @@ -/* - * 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:flutter_riverpod/flutter_riverpod.dart'; - -import '../wallet_info_provider.dart'; - -/// Provides the list of Solana SPL token mint addresses for a wallet. -/// -/// This is a family provider that takes a walletId and returns the list of -/// mint addresses from the WalletInfo's otherData. -final pSolanaWalletTokenAddresses = Provider.family, String>( - (ref, walletId) { - final walletInfo = ref.watch(pWalletInfo(walletId)); - return walletInfo.solanaTokenMintAddresses; - }, -); diff --git a/lib/wallets/isar/providers/wallet_info_provider.dart b/lib/wallets/isar/providers/wallet_info_provider.dart index d6469879e..c79ab8a56 100644 --- a/lib/wallets/isar/providers/wallet_info_provider.dart +++ b/lib/wallets/isar/providers/wallet_info_provider.dart @@ -96,13 +96,21 @@ final pWalletReceivingAddress = Provider.family(( ); }); +/// Provider for wallet token addresses (Ethereum) or token mint addresses (Solana). +/// +/// Returns the appropriate token list based on the wallet's coin type. +/// +/// For Ethereum wallets: returns tokenContractAddresses. +/// For Solana wallets: returns solanaTokenMintAddresses. final pWalletTokenAddresses = Provider.family, String>(( ref, walletId, ) { - return ref.watch( - _wiProvider( - walletId, - ).select((value) => (value.value as WalletInfo).tokenContractAddresses), - ); + final walletInfo = ref.watch(pWalletInfo(walletId)); + + if (walletInfo.coin.prettyName == 'Solana') { + return walletInfo.solanaTokenMintAddresses; + } else { + return walletInfo.tokenContractAddresses; + } }); From aa87ab1d74f2064b2122d0e6e0188cf412db493a Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 13 Nov 2025 18:01:20 -0600 Subject: [PATCH 42/80] feat(spl): cache token transfers --- .../models/blockchain_data/transaction.dart | 1 + .../impl/sub_wallets/solana_token_wallet.dart | 72 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/lib/models/isar/models/blockchain_data/transaction.dart b/lib/models/isar/models/blockchain_data/transaction.dart index 3e43ffb21..07b4b912f 100644 --- a/lib/models/isar/models/blockchain_data/transaction.dart +++ b/lib/models/isar/models/blockchain_data/transaction.dart @@ -261,4 +261,5 @@ enum TransactionSubType { sparkSpend, // firo specific ordinal, mweb, + splToken, // Solana token (SPL). } diff --git a/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart b/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart index 2de296b82..720e23847 100644 --- a/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart +++ b/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart @@ -15,6 +15,10 @@ import 'package:solana/solana.dart' hide Wallet; import '../../../../db/isar/main_db.dart'; import '../../../../models/balance.dart'; +import '../../../../models/isar/models/blockchain_data/transaction.dart'; +import '../../../../models/isar/models/blockchain_data/v2/input_v2.dart'; +import '../../../../models/isar/models/blockchain_data/v2/output_v2.dart'; +import '../../../../models/isar/models/blockchain_data/v2/transaction_v2.dart'; import '../../../../models/paymint/fee_object_model.dart'; import '../../../../services/solana/solana_token_api.dart'; import '../../../../utilities/amount/amount.dart'; @@ -279,6 +283,74 @@ class SolanaTokenWallet extends Wallet { ); } + // Create temporary transaction (pending = unconfirmed) and save to db. + try { + // Build inputs and outputs for the transaction record. + final inputs = [ + InputV2.isarCantDoRequiredInDefaultConstructor( + scriptSigHex: null, + scriptSigAsm: null, + sequence: null, + outpoint: null, + addresses: [senderTokenAccount], + valueStringSats: txData.amount!.raw.toString(), + witness: null, + innerRedeemScriptAsm: null, + coinbase: null, + walletOwns: true, + ), + ]; + + final outputs = [ + OutputV2.isarCantDoRequiredInDefaultConstructor( + scriptPubKeyHex: "00", + valueStringSats: txData.amount!.raw.toString(), + addresses: [recipientTokenAccount], + walletOwns: false, // We don't own recipient account. + ), + ]; + + // Determine if this is a self-transfer. + final isToSelf = senderTokenAccount == recipientTokenAccount; + + // Create the temporary transaction record. + final tempTx = TransactionV2( + walletId: walletId, + blockHash: null, // CRITICAL: null indicates pending. + hash: txid, + txid: txid, + timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, + height: null, // CRITICAL: null indicates pending. + inputs: List.unmodifiable(inputs), + outputs: List.unmodifiable(outputs), + version: -1, + type: isToSelf + ? TransactionType.sentToSelf + : TransactionType.outgoing, + subType: TransactionSubType.splToken, + otherData: jsonEncode({ + "mint": tokenMint, + "senderTokenAccount": senderTokenAccount, + "recipientTokenAccount": recipientTokenAccount, + "isCancelled": false, + "overrideFee": txData.fee!.toJsonString(), + }), + ); + + // Persist immediately to database so UI shows transaction right away. + await mainDB.updateOrPutTransactionV2s([tempTx]); + Logging.instance.i( + "$runtimeType confirmSend: Persisted pending transaction $txid to database", + ); + } catch (e, s) { + // Log persistence error but don't fail the send operation. + Logging.instance.w( + "$runtimeType confirmSend: Failed to persist pending transaction to database: ", + error: e, + stackTrace: s, + ); + } + // Wait for confirmation. final confirmed = await _waitForConfirmation( signature: txid, From c3a1cdcda4d9403dd983a1129dcdeb355744e1c9 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 14 Nov 2025 11:15:48 -0600 Subject: [PATCH 43/80] feat(spl): cache local sol & spl txs and update txs as they confirm --- lib/wallets/wallet/impl/solana_wallet.dart | 249 +++++++++++++----- .../impl/sub_wallets/solana_token_wallet.dart | 165 +++++++++++- 2 files changed, 351 insertions(+), 63 deletions(-) diff --git a/lib/wallets/wallet/impl/solana_wallet.dart b/lib/wallets/wallet/impl/solana_wallet.dart index a0791519a..7f9c941db 100644 --- a/lib/wallets/wallet/impl/solana_wallet.dart +++ b/lib/wallets/wallet/impl/solana_wallet.dart @@ -13,6 +13,9 @@ import '../../../app_config.dart'; import '../../../exceptions/wallet/node_tor_mismatch_config_exception.dart'; import '../../../models/balance.dart'; import '../../../models/isar/models/blockchain_data/transaction.dart' as isar; +import '../../../models/isar/models/blockchain_data/v2/input_v2.dart'; +import '../../../models/isar/models/blockchain_data/v2/output_v2.dart'; +import '../../../models/isar/models/blockchain_data/v2/transaction_v2.dart'; import '../../../models/isar/models/isar_models.dart'; import '../../../models/node_model.dart'; import '../../../models/paymint/fee_object_model.dart'; @@ -217,6 +220,52 @@ class SolanaWallet extends Bip39Wallet { ); final txid = await _rpcClient?.signAndSendTransaction(message, [keyPair]); + + // Persist pending transaction immediately so UI shows "Sending" status. + if (txid != null) { + final senderAddress = keyPair.address; + final isToSelf = senderAddress == recipientAccount.address; + + final tempTx = TransactionV2( + walletId: walletId, + blockHash: null, // CRITICAL: indicates pending. + hash: txid, + txid: txid, + timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, + height: null, // CRITICAL: indicates pending. + inputs: [ + InputV2.isarCantDoRequiredInDefaultConstructor( + scriptSigHex: null, + scriptSigAsm: null, + sequence: null, + outpoint: null, + addresses: [senderAddress], + valueStringSats: txData.amount!.raw.toString(), + witness: null, + innerRedeemScriptAsm: null, + coinbase: null, + walletOwns: true, + ), + ], + outputs: [ + OutputV2.isarCantDoRequiredInDefaultConstructor( + scriptPubKeyHex: "00", + valueStringSats: txData.amount!.raw.toString(), + addresses: [recipientAccount.address], + walletOwns: isToSelf, + ), + ], + version: -1, + type: isToSelf ? isar.TransactionType.sentToSelf : isar.TransactionType.outgoing, + subType: isar.TransactionSubType.none, + otherData: jsonEncode({ + "overrideFee": txData.fee!.toJsonString(), + }), + ); + + await mainDB.updateOrPutTransactionV2s([tempTx]); + } + return txData.copyWith(txid: txid); } catch (e, s) { Logging.instance.e( @@ -253,7 +302,7 @@ class SolanaWallet extends Bip39Wallet { final fee = await _getEstimatedNetworkFee( Amount.fromDecimal( - Decimal.one, // 1 SOL + Decimal.one, // 1 SOL. fractionDigits: cryptoCurrency.fractionDigits, ), ); @@ -411,81 +460,157 @@ class SolanaWallet extends Bip39Wallet { (await _getKeyPair()).publicKey, encoding: Encoding.jsonParsed, ); - final txsList = List>.empty( - growable: true, - ); final myAddress = (await getCurrentReceivingAddress())!; - // TODO [prio=low]: Revisit null assertion below. + if (transactionsList == null) { + return; + } - for (final tx in transactionsList!) { - final senderAddress = - (tx.transaction as ParsedTransaction).message.accountKeys[0].pubkey; - var receiverAddress = - (tx.transaction as ParsedTransaction).message.accountKeys[1].pubkey; - var txType = isar.TransactionType.unknown; - final txAmount = Amount( - rawValue: BigInt.from( + final txns = []; + int skippedCount = 0; + + for (final tx in transactionsList) { + try { + // Skip transactions without metadata. + if (tx.meta == null) { + skippedCount++; + continue; + } + + if (tx.transaction is! ParsedTransaction) { + skippedCount++; + continue; + } + + final parsedTx = tx.transaction as ParsedTransaction; + final txid = parsedTx.signatures.isNotEmpty ? parsedTx.signatures[0] : null; + if (txid == null) { + skippedCount++; + continue; + } + + // Determine transaction direction. + final senderAddress = parsedTx.message.accountKeys[0].pubkey; + var receiverAddress = + parsedTx.message.accountKeys.length > 1 + ? parsedTx.message.accountKeys[1].pubkey + : senderAddress; + var txType = isar.TransactionType.unknown; + + if ((senderAddress == myAddress.value) && + (receiverAddress == "11111111111111111111111111111111")) { + // System Program account means sent to self. + txType = isar.TransactionType.sentToSelf; + receiverAddress = senderAddress; + } else if (senderAddress == myAddress.value) { + txType = isar.TransactionType.outgoing; + } else if (receiverAddress == myAddress.value) { + txType = isar.TransactionType.incoming; + } + + // Calculate transfer amount. + final amount = BigInt.from( tx.meta!.postBalances[1] - tx.meta!.preBalances[1], - ), - fractionDigits: cryptoCurrency.fractionDigits, - ); + ); - if ((senderAddress == myAddress.value) && - (receiverAddress == "11111111111111111111111111111111")) { - // The account that is only 1's are System Program accounts which - // means there is no receiver except the sender, - // see: https://explorer.solana.com/address/11111111111111111111111111111111 - txType = isar.TransactionType.sentToSelf; - receiverAddress = senderAddress; - } else if (senderAddress == myAddress.value) { - txType = isar.TransactionType.outgoing; - } else if (receiverAddress == myAddress.value) { - txType = isar.TransactionType.incoming; - } + // Check if this transaction already exists. + // If it does, preserve the overrideFee from the pending transaction. + dynamic existingOverrideFee; + try { + final allTxsForWallet = await mainDB.isar.transactionV2s + .where() + .walletIdEqualTo(walletId) + .findAll(); + for (final existingTx in allTxsForWallet) { + if (existingTx.txid == txid) { + final existingOtherData = existingTx.otherData; + if (existingOtherData != null && existingOtherData.isNotEmpty) { + try { + final otherDataMap = jsonDecode(existingOtherData); + if (otherDataMap is Map && + otherDataMap.containsKey('overrideFee')) { + existingOverrideFee = otherDataMap['overrideFee']; + } + } catch (e) { + // Ignore parsing errors. + } + } + break; + } + } + } catch (e) { + // Ignore database query errors. + } + + // Build otherData, preserving overrideFee if it existed. + final otherDataMap = {}; + if (existingOverrideFee != null) { + otherDataMap["overrideFee"] = existingOverrideFee; + } + + // Create TransactionV2 object. + final txn = TransactionV2( + walletId: walletId, + blockHash: null, + hash: txid, + txid: txid, + timestamp: tx.blockTime ?? DateTime.now().millisecondsSinceEpoch ~/ 1000, + height: tx.slot, + inputs: [ + InputV2.isarCantDoRequiredInDefaultConstructor( + scriptSigHex: null, + scriptSigAsm: null, + sequence: null, + outpoint: null, + addresses: [senderAddress], + valueStringSats: amount.toString(), + witness: null, + innerRedeemScriptAsm: null, + coinbase: null, + walletOwns: senderAddress == myAddress.value, + ), + ], + outputs: [ + OutputV2.isarCantDoRequiredInDefaultConstructor( + scriptPubKeyHex: "00", + valueStringSats: amount.toString(), + addresses: [receiverAddress], + walletOwns: receiverAddress == myAddress.value, + ), + ], + version: -1, + type: txType, + subType: isar.TransactionSubType.none, + otherData: otherDataMap.isNotEmpty ? jsonEncode(otherDataMap) : null, + ); - final transaction = isar.Transaction( - walletId: walletId, - txid: (tx.transaction as ParsedTransaction).signatures[0], - timestamp: tx.blockTime!, - type: txType, - subType: isar.TransactionSubType.none, - amount: tx.meta!.postBalances[1] - tx.meta!.preBalances[1], - amountString: txAmount.toJsonString(), - fee: tx.meta!.fee, - height: tx.slot, - isCancelled: false, - isLelantus: false, - slateId: null, - otherData: null, - inputs: [], - outputs: [], - nonce: null, - numberOfMessages: 0, - ); + txns.add(txn); + } catch (e, s) { + Logging.instance.w( + "$runtimeType updateTransactions: Failed to parse transaction", + error: e, + stackTrace: s, + ); + skippedCount++; + continue; + } + } - final txAddress = Address( - walletId: walletId, - value: receiverAddress, - publicKey: List.empty(), - derivationIndex: 0, - derivationPath: DerivationPath()..value = _addressDerivationPath, - type: AddressType.solana, - subType: txType == isar.TransactionType.outgoing - ? AddressSubType.unknown - : AddressSubType.receiving, + // Persist all transactions if any were parsed. + if (txns.isNotEmpty) { + await mainDB.updateOrPutTransactionV2s(txns); + Logging.instance.i( + "$runtimeType updateTransactions: Synced ${txns.length} transactions (skipped $skippedCount)", ); - - txsList.add(Tuple2(transaction, txAddress)); } - await mainDB.addNewTransactionData(txsList, walletId); } on NodeTorMismatchConfigException { rethrow; } catch (e, s) { Logging.instance.e( - "Error occurred in solana_wallet.dart while getting" - " transactions for solana: $e\n$s", + "$runtimeType updateTransactions failed: ", + error: e, + stackTrace: s, ); } } diff --git a/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart b/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart index 720e23847..c41ca6cb4 100644 --- a/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart +++ b/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart @@ -389,7 +389,169 @@ class SolanaTokenWallet extends Wallet { @override Future updateTransactions() async { - // TODO: Fetch token transfer history from Solana RPC. + try { + final rpcClient = parentSolanaWallet.getRpcClient(); + if (rpcClient == null) { + Logging.instance.w( + "$runtimeType updateTransactions: RPC client not initialized", + ); + return; + } + + final keyPair = await parentSolanaWallet.getKeyPair(); + final walletAddress = keyPair.address; + + // Find token account for this mint. + final senderTokenAccount = await _findTokenAccount( + ownerAddress: walletAddress, + mint: tokenMint, + rpcClient: rpcClient, + ); + + if (senderTokenAccount == null) { + return; + } + + // Fetch recent transactions for this token account. + final txListIterable = await rpcClient.getTransactionsList( + Ed25519HDPublicKey.fromBase58(senderTokenAccount), + encoding: Encoding.jsonParsed, + ); + + final txList = txListIterable.toList(); + + if (txList.isEmpty) { + return; + } + + final txns = []; + int skippedCount = 0; + + for (int i = 0; i < txList.length; i++) { + final txDetails = txList[i]; + try { + // Skip failed transactions or those without metadata. + if (txDetails.meta == null) { + skippedCount++; + continue; + } + + // Cast transaction to ParsedTransaction if available. + if (txDetails.transaction is! ParsedTransaction) { + skippedCount++; + continue; + } + final parsedTx = txDetails.transaction as ParsedTransaction; + + // Get the txid for this transaction + final txid = parsedTx.signatures.isNotEmpty + ? parsedTx.signatures[0] + : "unknown_txid_$i"; + + // Check if this transaction already exists in the database. + // If it does, preserve the overrideFee from the pending transaction. + dynamic existingOverrideFee; + try { + final allTxsForWallet = await mainDB.isar.transactionV2s + .where() + .walletIdEqualTo(walletId) + .findAll(); + for (final tx in allTxsForWallet) { + if (tx.txid == txid) { + final existingOtherData = tx.otherData; + if (existingOtherData != null && existingOtherData.isNotEmpty) { + try { + final otherDataMap = jsonDecode(existingOtherData); + if (otherDataMap is Map && + otherDataMap.containsKey('overrideFee')) { + existingOverrideFee = otherDataMap['overrideFee']; + } + } catch (e) { + // Ignore parsing errors. + } + } + break; + } + } + } catch (e) { + // Ignore database query errors. + } + + // Build otherData, preserving overrideFee if it existed. + final otherDataMap = { + "mint": tokenMint, + "senderTokenAccount": senderTokenAccount, + "recipientTokenAccount": senderTokenAccount, + "isCancelled": (txDetails.meta!.err != null), + }; + if (existingOverrideFee != null) { + otherDataMap["overrideFee"] = existingOverrideFee; + } + + // Create placeholder TransactionV2 object. + final txn = TransactionV2( + walletId: walletId, + blockHash: null, + hash: txid, + txid: txid, + timestamp: + txDetails.blockTime ?? + DateTime.now().millisecondsSinceEpoch ~/ 1000, + height: txDetails.slot, + inputs: [ + InputV2.isarCantDoRequiredInDefaultConstructor( + scriptSigHex: null, + scriptSigAsm: null, + sequence: null, + outpoint: null, + addresses: [senderTokenAccount], + valueStringSats: "0", + witness: null, + innerRedeemScriptAsm: null, + coinbase: null, + walletOwns: true, + ), + ], + outputs: [ + OutputV2.isarCantDoRequiredInDefaultConstructor( + scriptPubKeyHex: "00", + valueStringSats: "0", + addresses: [senderTokenAccount], + walletOwns: false, + ), + ], + version: -1, + type: TransactionType.outgoing, + subType: TransactionSubType.splToken, + otherData: jsonEncode(otherDataMap), + ); + + txns.add(txn); + } catch (e, s) { + Logging.instance.w( + "$runtimeType updateTransactions: Failed to parse transaction at index $i", + error: e, + stackTrace: s, + ); + skippedCount++; + continue; + } + } + + // Persist all transactions if any were parsed. + if (txns.isNotEmpty) { + await mainDB.updateOrPutTransactionV2s(txns); + Logging.instance.i( + "$runtimeType updateTransactions: Synced ${txns.length} transactions (skipped $skippedCount)", + ); + } + } catch (e, s) { + Logging.instance.e( + "$runtimeType updateTransactions FAILED: ", + error: e, + stackTrace: s, + ); + } } @override @@ -518,6 +680,7 @@ class SolanaTokenWallet extends Wallet { // This ensures the cached token balance in the database is updated. await parentSolanaWallet.refresh(); await updateBalance(); + await updateTransactions(); } @override From 313fada21bd13de9a27efdc67ae224ddf7acf597 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 18 Nov 2025 19:42:19 -0600 Subject: [PATCH 44/80] feat(spl): add custom token mint addresses storage to WalletInfo --- lib/wallets/isar/models/wallet_info.dart | 26 ++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/lib/wallets/isar/models/wallet_info.dart b/lib/wallets/isar/models/wallet_info.dart index f4dbab507..8cb0f6a58 100644 --- a/lib/wallets/isar/models/wallet_info.dart +++ b/lib/wallets/isar/models/wallet_info.dart @@ -86,6 +86,17 @@ class WalletInfo implements IsarId { } } + @ignore + List get solanaCustomTokenMintAddresses { + if (otherData[WalletInfoKeys.solanaCustomTokenMintAddresses] is List) { + return List.from( + otherData[WalletInfoKeys.solanaCustomTokenMintAddresses] as List, + ); + } else { + return []; + } + } + /// Special case for coins such as firo lelantus @ignore Balance get cachedBalanceSecondary { @@ -420,6 +431,19 @@ class WalletInfo implements IsarId { ); } + /// Update custom Solana token mint addresses and update the db. + Future updateSolanaCustomTokenMintAddresses({ + required Set newMintAddresses, + required Isar isar, + }) async { + await updateOtherData( + newEntries: { + WalletInfoKeys.solanaCustomTokenMintAddresses: newMintAddresses.toList(), + }, + isar: isar, + ); + } + Future setMwebEnabled({ required bool newValue, required Isar isar, @@ -549,4 +573,6 @@ abstract class WalletInfoKeys { static const String firoSparkUsedTagsCacheResetVersion = "firoSparkUsedTagsCacheResetVersionKey"; static const String solanaTokenMintAddresses = "solanaTokenMintAddressesKey"; + static const String solanaCustomTokenMintAddresses = + "solanaCustomTokenMintAddressesKey"; } From c5380d9e1a2dd8cb035a480c6ea8d610bbb0b9e9 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 18 Nov 2025 19:38:11 -0600 Subject: [PATCH 45/80] feat(spl): Token-2022 support for SPL token balance fetching --- lib/services/solana/solana_token_api.dart | 256 +++++++++++++++++----- 1 file changed, 205 insertions(+), 51 deletions(-) diff --git a/lib/services/solana/solana_token_api.dart b/lib/services/solana/solana_token_api.dart index 4feb4b01f..b5a40272f 100644 --- a/lib/services/solana/solana_token_api.dart +++ b/lib/services/solana/solana_token_api.dart @@ -15,33 +15,26 @@ class SolanaTokenApiException implements Exception { final String message; final Exception? originalException; - SolanaTokenApiException( - this.message, { - this.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 { final T? value; final Exception? exception; - SolanaTokenApiResponse({ - this.value, - this.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)'; + String toString() => isSuccess ? 'Success($value)' : 'Error($exception)'; } /// Data class for token account information. @@ -90,12 +83,16 @@ class TokenAccountInfo { final owner = info['owner']; final mint = info['mint']; final tokenAmount = info['tokenAmount']; - final amountStr = (tokenAmount is Map) ? (tokenAmount as Map)['amount'] : null; - final decimalsVal = (tokenAmount is Map) ? (tokenAmount as Map)['decimals'] : null; + final amountStr = (tokenAmount is Map) + ? (tokenAmount as Map)['amount'] + : null; + final decimalsVal = (tokenAmount is Map) + ? (tokenAmount as Map)['decimals'] + : null; final isNative = (parsed is Map) ? ((parsed as Map)['type'] == 'account' && - (parsed as Map)['program'] == 'spl-token') + (parsed as Map)['program'] == 'spl-token') : false; return TokenAccountInfo( @@ -103,7 +100,9 @@ class TokenAccountInfo { 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), + decimals: decimalsVal is int + ? decimalsVal + : (int.tryParse(decimalsVal?.toString() ?? '0') ?? 0), isNative: isNative, ); } @@ -168,9 +167,7 @@ class SolanaTokenAPI { .map((account) => account.pubkey) .toList(); - return SolanaTokenApiResponse>( - value: accountAddresses, - ); + return SolanaTokenApiResponse>(value: accountAddresses); } on Exception catch (e) { return SolanaTokenApiResponse>( exception: SolanaTokenApiException( @@ -201,9 +198,7 @@ class SolanaTokenAPI { if (response.value == null) { // Token account doesn't exist. - return SolanaTokenApiResponse( - value: BigInt.zero, - ); + return SolanaTokenApiResponse(value: BigInt.zero); } final accountData = response.value!; @@ -211,8 +206,12 @@ class SolanaTokenAPI { // Extract token amount from parsed data. try { // Debug: Print the structure of accountData. - print('[SOLANA_TOKEN_API] accountData type: ${accountData.runtimeType}'); - print('[SOLANA_TOKEN_API] accountData.data type: ${accountData.data.runtimeType}'); + print( + '[SOLANA_TOKEN_API] accountData type: ${accountData.runtimeType}', + ); + print( + '[SOLANA_TOKEN_API] accountData.data type: ${accountData.data.runtimeType}', + ); print('[SOLANA_TOKEN_API] accountData.data: ${accountData.data}'); // The solana package returns a ParsedAccountData which is a sealed class/union type. @@ -233,15 +232,23 @@ class SolanaTokenAPI { account: (info, type, accountType) { print('[SOLANA_TOKEN_API] Handling account variant'); print('[SOLANA_TOKEN_API] info type: ${info.runtimeType}'); - print('[SOLANA_TOKEN_API] info.tokenAmount: ${info.tokenAmount}'); + print( + '[SOLANA_TOKEN_API] info.tokenAmount: ${info.tokenAmount}', + ); try { final tokenAmount = info.tokenAmount; - print('[SOLANA_TOKEN_API] tokenAmount.amount: ${tokenAmount.amount}'); - print('[SOLANA_TOKEN_API] tokenAmount.decimals: ${tokenAmount.decimals}'); + print( + '[SOLANA_TOKEN_API] tokenAmount.amount: ${tokenAmount.amount}', + ); + print( + '[SOLANA_TOKEN_API] tokenAmount.decimals: ${tokenAmount.decimals}', + ); final balanceBigInt = BigInt.parse(tokenAmount.amount); - print('[SOLANA_TOKEN_API] Successfully extracted balance: $balanceBigInt'); + print( + '[SOLANA_TOKEN_API] Successfully extracted balance: $balanceBigInt', + ); return balanceBigInt; } catch (e) { print('[SOLANA_TOKEN_API] Error extracting balance: $e'); @@ -249,7 +256,9 @@ class SolanaTokenAPI { } }, mint: (info, type, accountType) { - print('[SOLANA_TOKEN_API] Got mint variant (not expected for token account balance)'); + print( + '[SOLANA_TOKEN_API] Got mint variant (not expected for token account balance)', + ); return null; }, unknown: (type) { @@ -259,12 +268,55 @@ class SolanaTokenAPI { ); }, stake: (_) { - print('[SOLANA_TOKEN_API] Got stake account type (not expected)'); + print( + '[SOLANA_TOKEN_API] Got stake account type (not expected)', + ); return null; }, - token2022: (_) { - print('[SOLANA_TOKEN_API] Got token2022 account type (not expected)'); - return null; + token2022: (token2022Data) { + print( + '[SOLANA_TOKEN_API] Handling token2022 account type', + ); + print('[SOLANA_TOKEN_API] token2022Data type: ${token2022Data.runtimeType}'); + + return token2022Data.when( + account: (info, type, accountType) { + print('[SOLANA_TOKEN_API] Handling token2022 account variant'); + print('[SOLANA_TOKEN_API] info type: ${info.runtimeType}'); + print( + '[SOLANA_TOKEN_API] info.tokenAmount: ${info.tokenAmount}', + ); + + try { + final tokenAmount = info.tokenAmount; + print( + '[SOLANA_TOKEN_API] tokenAmount.amount: ${tokenAmount.amount}', + ); + print( + '[SOLANA_TOKEN_API] tokenAmount.decimals: ${tokenAmount.decimals}', + ); + + final balanceBigInt = BigInt.parse(tokenAmount.amount); + print( + '[SOLANA_TOKEN_API] Successfully extracted token2022 balance: $balanceBigInt', + ); + return balanceBigInt; + } catch (e) { + print('[SOLANA_TOKEN_API] Error extracting token2022 balance: $e'); + return null; + } + }, + mint: (info, type, accountType) { + print( + '[SOLANA_TOKEN_API] Got token2022 mint variant (not expected for token account balance)', + ); + return null; + }, + unknown: (type) { + print('[SOLANA_TOKEN_API] Got unknown token2022 account variant'); + return null; + }, + ); }, unsupported: (_) { print('[SOLANA_TOKEN_API] Got unsupported account type'); @@ -286,15 +338,11 @@ class SolanaTokenAPI { // If we can't extract from the Dart object, return zero. print('[SOLANA_TOKEN_API] Returning zero balance'); - return SolanaTokenApiResponse( - value: BigInt.zero, - ); + return SolanaTokenApiResponse(value: BigInt.zero); } catch (e) { // If parsing fails, return zero balance. print('[SOLANA_TOKEN_API] Exception during parsing: $e'); - return SolanaTokenApiResponse( - value: BigInt.zero, - ); + return SolanaTokenApiResponse(value: BigInt.zero); } } on Exception catch (e) { return SolanaTokenApiResponse( @@ -312,14 +360,17 @@ class SolanaTokenAPI { /// - 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 + /// + /// NOTE: Currently returns placeholder data for UI placeholders. + /// + /// TODO: Implement full RPC call when API is ready. Future> getTokenSupply(String mint) async { try { _checkClient(); - // TODO: Get the mint account info when RPC APIs are stable - // For now return placeholder mock data + // TODO: Get the mint account info when RPC APIs are stable. + // + // For now return placeholder mock data. return SolanaTokenApiResponse( value: BigInt.parse('1000000000000000000'), ); @@ -339,15 +390,18 @@ class SolanaTokenAPI { /// - tokenAccountAddress: The token account address. /// /// Returns detailed token account information. + /// + /// Currently returns placeholder data for UI placeholders. /// - /// Currently returns placeholder data for UI development. /// TODO: Implement full RPC call when API is ready. - Future> - getTokenAccountInfo(String tokenAccountAddress) async { + Future> getTokenAccountInfo( + String tokenAccountAddress, + ) async { try { _checkClient(); // Return placeholder data. + // // TODO: Implement actual RPC call using proper client methods. return SolanaTokenApiResponse( value: TokenAccountInfo( @@ -376,12 +430,10 @@ class SolanaTokenAPI { /// - mint: The token mint address. /// /// Returns the derived ATA address. - String findAssociatedTokenAddress( - String ownerAddress, - String mint, - ) { + String findAssociatedTokenAddress(String ownerAddress, String mint) { // Return a placeholder. - // TODO: Implement ATA derivation using Solana SDK. + // + // TODO: Implement ATA derivation using Solana package. return ''; } @@ -403,7 +455,8 @@ class SolanaTokenAPI { } // If we got token accounts, the user owns this token. - final hasTokenAccount = accounts.value != null && (accounts.value as List).isNotEmpty; + final hasTokenAccount = + accounts.value != null && (accounts.value as List).isNotEmpty; return SolanaTokenApiResponse(value: hasTokenAccount); } on Exception catch (e) { return SolanaTokenApiResponse( @@ -414,4 +467,105 @@ class SolanaTokenAPI { ); } } + + /// Fetch SPL token metadata from Solana metadata program. + /// + /// The Solana Token Metadata program (metaqbxxUerdq28cj1RbAqWwTRiWLs6nshmbbuP3xqb) + /// stores token metadata at a PDA derived from the mint address. + /// + /// Returns: Map with name, symbol, decimals, and optional logo URI + /// Returns null if metadata cannot be found (user can enter custom details), + /// + /// Note: Full PDA derivation is not yet implemented in the solana package. + /// Currently returns null to allow users to manually enter token details. + Future?>> + fetchTokenMetadataByMint( + String mintAddress, + ) async { + try { + _checkClient(); + + // TODO: Implement proper metadata PDA derivation when solana package + // exposes findProgramAddress() utilities. + // + // The Solana Token Metadata program (metaqbxxUerdq28cj1RbAqWwTRiWLs6nshmbbuP3xqb) + // stores token metadata at a PDA derived from the mint address using: + // findProgramAddress( + // ["metadata", metadataProgram, mintPubkey], + // metadataProgram + // ) + // + // Until then, return null to allow users to enter custom token details. + + // Metadata PDA derivation not yet implemented + return SolanaTokenApiResponse?>( + value: null, + ); + } on Exception { + // On error, return null to allow user to manually enter token details + return SolanaTokenApiResponse?>( + value: null, + ); + } + } + + /// Validate if a string is a valid Solana mint address. + /// + /// A valid Solana address must: + /// - Be base58 encoded + /// - Be between 40-50 characters long + /// - Represent a valid Ed25519 public key + /// + /// Returns: true if valid, false otherwise. + bool isValidSolanaMintAddress(String address) { + try { + // Check length (Solana addresses are ~44 chars in base58). + if (address.length < 40 || address.length > 50) return false; + + // Try to parse as Ed25519 public key from base58. + Ed25519HDPublicKey.fromBase58(address); + + // Valid if parsing succeeds. + return true; + } catch (e) { + return false; + } + } + + /// Derive the metadata PDA for a given mint address. + /// + /// This is a temporary implementation that queries known metadata endpoints. + /// In production, this should use solana package's findProgramAddress utilities. + /// + /// Returns: metadata PDA address or null if derivation fails + Future _deriveMetadataPda(String mintAddress) async { + try { + // Validate the mint address first + if (!isValidSolanaMintAddress(mintAddress)) { + return null; + } + + // TODO: Implement proper PDA derivation using solana package's findProgramAddress + // This is a placeholder that would need to be updated when solana package + // exposes the necessary utilities + // + // For now, we return null to trigger fallback behavior + // In a real implementation, you would derive the PDA like: + // final seeds = [ + // 'metadata'.codeUnits, + // metadataProgram.toBytes(), + // mint.toBytes(), + // ]; + // final (pda, _) = Ed25519HDPublicKey.findProgramAddress( + // seeds, + // metadataProgram, + // ); + // return pda.toBase58(); + + return null; + } catch (e) { + return null; + } + } + } From 67d4fdd7a6f71a9107e2abe1d423a5e674e6b3d8 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 18 Nov 2025 19:43:27 -0600 Subject: [PATCH 46/80] feat(spl): save custom tokens and include in wallet token list --- .../isar/providers/wallet_info_provider.dart | 9 +++- lib/wallets/wallet/impl/solana_wallet.dart | 45 +++++++++++++------ 2 files changed, 39 insertions(+), 15 deletions(-) diff --git a/lib/wallets/isar/providers/wallet_info_provider.dart b/lib/wallets/isar/providers/wallet_info_provider.dart index c79ab8a56..354658dbf 100644 --- a/lib/wallets/isar/providers/wallet_info_provider.dart +++ b/lib/wallets/isar/providers/wallet_info_provider.dart @@ -101,7 +101,7 @@ final pWalletReceivingAddress = Provider.family(( /// Returns the appropriate token list based on the wallet's coin type. /// /// For Ethereum wallets: returns tokenContractAddresses. -/// For Solana wallets: returns solanaTokenMintAddresses. +/// For Solana wallets: returns solanaTokenMintAddresses + solanaCustomTokenMintAddresses combined. final pWalletTokenAddresses = Provider.family, String>(( ref, walletId, @@ -109,7 +109,12 @@ final pWalletTokenAddresses = Provider.family, String>(( final walletInfo = ref.watch(pWalletInfo(walletId)); if (walletInfo.coin.prettyName == 'Solana') { - return walletInfo.solanaTokenMintAddresses; + // Combine both default and custom token mint addresses. + final allTokens = { + ...walletInfo.solanaTokenMintAddresses, + ...walletInfo.solanaCustomTokenMintAddresses, + }; + return allTokens.toList(); } else { return walletInfo.tokenContractAddresses; } diff --git a/lib/wallets/wallet/impl/solana_wallet.dart b/lib/wallets/wallet/impl/solana_wallet.dart index 7f9c941db..6caa87516 100644 --- a/lib/wallets/wallet/impl/solana_wallet.dart +++ b/lib/wallets/wallet/impl/solana_wallet.dart @@ -7,7 +7,6 @@ import 'package:isar_community/isar.dart'; import 'package:socks5_proxy/socks_client.dart'; import 'package:solana/dto.dart'; import 'package:solana/solana.dart'; -import 'package:tuple/tuple.dart'; import '../../../app_config.dart'; import '../../../exceptions/wallet/node_tor_mismatch_config_exception.dart'; @@ -19,6 +18,8 @@ import '../../../models/isar/models/blockchain_data/v2/transaction_v2.dart'; import '../../../models/isar/models/isar_models.dart'; import '../../../models/node_model.dart'; import '../../../models/paymint/fee_object_model.dart'; +import '../../../services/event_bus/events/global/updated_in_background_event.dart'; +import '../../../services/event_bus/global_event_bus.dart'; import '../../../services/node_service.dart'; import '../../../services/tor_service.dart'; import '../../../utilities/amount/amount.dart'; @@ -256,11 +257,11 @@ class SolanaWallet extends Bip39Wallet { ), ], version: -1, - type: isToSelf ? isar.TransactionType.sentToSelf : isar.TransactionType.outgoing, + type: isToSelf + ? isar.TransactionType.sentToSelf + : isar.TransactionType.outgoing, subType: isar.TransactionSubType.none, - otherData: jsonEncode({ - "overrideFee": txData.fee!.toJsonString(), - }), + otherData: jsonEncode({"overrideFee": txData.fee!.toJsonString()}), ); await mainDB.updateOrPutTransactionV2s([tempTx]); @@ -484,7 +485,9 @@ class SolanaWallet extends Bip39Wallet { } final parsedTx = tx.transaction as ParsedTransaction; - final txid = parsedTx.signatures.isNotEmpty ? parsedTx.signatures[0] : null; + final txid = parsedTx.signatures.isNotEmpty + ? parsedTx.signatures[0] + : null; if (txid == null) { skippedCount++; continue; @@ -492,10 +495,9 @@ class SolanaWallet extends Bip39Wallet { // Determine transaction direction. final senderAddress = parsedTx.message.accountKeys[0].pubkey; - var receiverAddress = - parsedTx.message.accountKeys.length > 1 - ? parsedTx.message.accountKeys[1].pubkey - : senderAddress; + var receiverAddress = parsedTx.message.accountKeys.length > 1 + ? parsedTx.message.accountKeys[1].pubkey + : senderAddress; var txType = isar.TransactionType.unknown; if ((senderAddress == myAddress.value) && @@ -555,7 +557,8 @@ class SolanaWallet extends Bip39Wallet { blockHash: null, hash: txid, txid: txid, - timestamp: tx.blockTime ?? DateTime.now().millisecondsSinceEpoch ~/ 1000, + timestamp: + tx.blockTime ?? DateTime.now().millisecondsSinceEpoch ~/ 1000, height: tx.slot, inputs: [ InputV2.isarCantDoRequiredInDefaultConstructor( @@ -582,7 +585,9 @@ class SolanaWallet extends Bip39Wallet { version: -1, type: txType, subType: isar.TransactionSubType.none, - otherData: otherDataMap.isNotEmpty ? jsonEncode(otherDataMap) : null, + otherData: otherDataMap.isNotEmpty + ? jsonEncode(otherDataMap) + : null, ); txns.add(txn); @@ -621,8 +626,22 @@ class SolanaWallet extends Bip39Wallet { return false; } + /// Update the list of custom Solana token mint addresses for this wallet. + Future updateSolanaTokens(Set mintAddresses) async { + await info.updateSolanaCustomTokenMintAddresses( + newMintAddresses: mintAddresses, + isar: mainDB.isar, + ); + + GlobalEventBus.instance.fire( + UpdatedInBackgroundEvent( + "Solana custom tokens updated for: $walletId ${info.name}", + walletId, + ), + ); + } + /// Make sure the Solana RpcClient uses Tor if it's enabled. - /// void _checkClient() { final node = getCurrentNode(); From 5e31a29d46e8ddb6c027a44009faaba4bd38d486 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 18 Nov 2025 19:38:40 -0600 Subject: [PATCH 47/80] feat(spl): fetch custom token metadata from db and update bal on open --- lib/pages/token_view/sol_token_view.dart | 34 ++++++++++++++----- .../wallet_view/desktop_sol_token_view.dart | 27 +++++++++++---- 2 files changed, 46 insertions(+), 15 deletions(-) diff --git a/lib/pages/token_view/sol_token_view.dart b/lib/pages/token_view/sol_token_view.dart index d206d2336..7efe2dc69 100644 --- a/lib/pages/token_view/sol_token_view.dart +++ b/lib/pages/token_view/sol_token_view.dart @@ -12,6 +12,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; +import '../../providers/db/main_db_provider.dart'; import '../../providers/providers.dart'; import '../../services/event_bus/events/global/wallet_sync_status_changed_event.dart'; import '../../themes/stack_colors.dart'; @@ -62,6 +63,7 @@ class _SolTokenViewState extends ConsumerState { : WalletSyncStatus.synced; // Initialize the Solana token wallet provider with mock data. + // // This sets up the pCurrentSolanaTokenWallet provider so that // SolanaTokenSummary can access the token wallet information. WidgetsBinding.instance.addPostFrameCallback((_) { @@ -74,29 +76,40 @@ class _SolTokenViewState extends ConsumerState { } /// Initialize the Solana token wallet for this token view. - /// - /// Creates a SolanaTokenWallet with token data from DefaultSplTokens - /// and sets it as the current token wallet in the provider so that UI widgets can access it. - /// - /// If the token is not found in DefaultSplTokens, sets the token wallet to null + /// + /// Creates a SolanaTokenWallet with token data from DefaultSplTokens or the database. + /// First looks in DefaultSplTokens, then checks the database for custom tokens. + /// Sets it as the current token wallet in the provider so that UI widgets can access it. + /// + /// If the token is not found anywhere, sets the token wallet to null /// so the UI can display an error message. - /// - /// TODO: Implement token data lookup for tokens not on the default list. void _initializeSolanaTokenWallet() { dynamic tokenInfo; + + // First try to find in default tokens. try { tokenInfo = DefaultSplTokens.list.firstWhere( (token) => token.address == widget.tokenMint, ); } catch (e) { - // Token not found in DefaultSplTokens. + // Token not found in DefaultSplTokens, try database for custom tokens. tokenInfo = null; } + // If not found in defaults, try database for custom tokens. + if (tokenInfo == null) { + try { + final db = ref.read(mainDBProvider); + tokenInfo = db.getSplTokenSync(widget.tokenMint); + } catch (e) { + tokenInfo = null; + } + } + if (tokenInfo == null) { ref.read(solanaTokenServiceStateProvider.state).state = null; debugPrint( - 'ERROR: Token not found in DefaultSplTokens: ${widget.tokenMint}', + 'ERROR: Token not found in DefaultSplTokens or database: ${widget.tokenMint}', ); return; } @@ -121,6 +134,9 @@ class _SolTokenViewState extends ConsumerState { ); ref.read(solanaTokenServiceStateProvider.state).state = solanaTokenWallet; + + // Fetch the token balance when the wallet is opened. + solanaTokenWallet.updateBalance(); } @override diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart index 65aa72b9d..f17c37a9f 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart @@ -14,6 +14,7 @@ import 'package:flutter_svg/svg.dart'; import '../../../pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart'; import '../../../pages/token_view/sub_widgets/token_transaction_list_widget_sol.dart'; +import '../../../providers/db/main_db_provider.dart'; import '../../../providers/providers.dart'; import '../../../services/event_bus/events/global/wallet_sync_status_changed_event.dart'; import '../../../themes/stack_colors.dart'; @@ -75,27 +76,38 @@ class _DesktopTokenViewState extends ConsumerState { /// Initialize the Solana token wallet. /// - /// Creates a SolanaTokenWallet with token data from DefaultSplTokens - /// and sets it as the current token wallet in the provider so that UI widgets can access it. + /// Creates a SolanaTokenWallet with token data from DefaultSplTokens or the database. + /// First looks in DefaultSplTokens, then checks the database for custom tokens. + /// Sets it as the current token wallet in the provider so that UI widgets can access it. /// - /// If the token is not found in DefaultSplTokens, sets the token wallet to null + /// If the token is not found anywhere, sets the token wallet to null /// so the UI can display an error message. void _initializeSolanaTokenWallet() { - // Look up the actual token from DefaultSplTokens. + // First try to find in default tokens dynamic tokenInfo; try { tokenInfo = DefaultSplTokens.list.firstWhere( (token) => token.address == widget.tokenMint, ); } catch (e) { - // Token not found in DefaultSplTokens. + // Token not found in DefaultSplTokens, try database for custom tokens tokenInfo = null; } + // If not found in defaults, try database for custom tokens + if (tokenInfo == null) { + try { + final db = ref.read(mainDBProvider); + tokenInfo = db.getSplTokenSync(widget.tokenMint); + } catch (e) { + tokenInfo = null; + } + } + if (tokenInfo == null) { ref.read(solanaTokenServiceStateProvider.state).state = null; debugPrint( - 'ERROR: Token not found in DefaultSplTokens: ${widget.tokenMint}', + 'ERROR: Token not found in DefaultSplTokens or database: ${widget.tokenMint}', ); return; } @@ -120,6 +132,9 @@ class _DesktopTokenViewState extends ConsumerState { ); ref.read(solanaTokenServiceStateProvider.state).state = solanaTokenWallet; + + // Fetch the token balance when the wallet is opened + solanaTokenWallet.updateBalance(); } @override From a93b19bd9c71305bad6d4abb0c894ce4af9cd310 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 18 Nov 2025 19:38:55 -0600 Subject: [PATCH 48/80] feat(spl): query database for custom tokens in addition to defaults --- .../sub_widgets/sol_tokens_list.dart | 46 ++++++++++++++----- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/lib/pages/token_view/sub_widgets/sol_tokens_list.dart b/lib/pages/token_view/sub_widgets/sol_tokens_list.dart index 070b0e8f1..d063fc130 100644 --- a/lib/pages/token_view/sub_widgets/sol_tokens_list.dart +++ b/lib/pages/token_view/sub_widgets/sol_tokens_list.dart @@ -9,8 +9,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:isar_community/isar.dart'; import '../../../models/isar/models/solana/spl_token.dart'; +import '../../../providers/db/main_db_provider.dart'; import '../../../utilities/default_spl_tokens.dart'; import '../../../utilities/util.dart'; import 'sol_token_select_item.dart'; @@ -41,10 +43,12 @@ class SolanaTokensList extends StatelessWidget { if (searchTerm.isNotEmpty) { final term = searchTerm.toLowerCase(); filtered = filtered - .where((token) => - token.name.toLowerCase().contains(term) || - token.symbol.toLowerCase().contains(term) || - token.address.toLowerCase().contains(term)) + .where( + (token) => + token.name.toLowerCase().contains(term) || + token.symbol.toLowerCase().contains(term) || + token.address.toLowerCase().contains(term), + ) .toList(); } @@ -57,9 +61,30 @@ class SolanaTokensList extends StatelessWidget { return Consumer( builder: (_, ref, __) { - // Get all available SPL tokens from the default list. - // TODO [prio=high]: This should be fetched from the database and/or API. - final allTokens = DefaultSplTokens.list; + // Get all available SPL tokens: combine defaults with custom tokens from database. + final db = ref.watch(mainDBProvider); + + // Query all SplTokens from the database (includes both defaults and custom tokens). + final allDatabaseTokens = db.getSplTokens().findAllSync(); + + // Combined token lists: prioritize database tokens, fall back to defaults. + final allTokens = []; + final seenAddresses = {}; + + // Add all database tokens. + for (final token in allDatabaseTokens) { + allTokens.add(token); + seenAddresses.add(token.address); + } + + // Add default tokens that aren't already in the database. + for (final defaultToken in DefaultSplTokens.list) { + if (!seenAddresses.contains(defaultToken.address)) { + allTokens.add(defaultToken); + seenAddresses.add(defaultToken.address); + } + } + final tokens = _filter(searchTerm, allTokens); if (tokens.isEmpty) { @@ -77,10 +102,9 @@ class SolanaTokensList extends StatelessWidget { final token = tokens[index]; return Padding( key: Key(token.address), - padding: - isDesktop - ? const EdgeInsets.symmetric(vertical: 5) - : const EdgeInsets.all(4), + padding: isDesktop + ? const EdgeInsets.symmetric(vertical: 5) + : const EdgeInsets.all(4), child: SolTokenSelectItem(walletId: walletId, token: token), ); }, From 0ecc5fd53577b3a53f0e1739b0894fc60e2bf6ac Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 18 Nov 2025 19:40:43 -0600 Subject: [PATCH 49/80] feat(spl): custom tokens with validation and error handling --- .../add_custom_solana_token_view.dart | 524 ++++++++++++++++++ .../edit_wallet_tokens_view.dart | 150 +++-- 2 files changed, 596 insertions(+), 78 deletions(-) create mode 100644 lib/pages/add_wallet_views/add_token_view/add_custom_solana_token_view.dart 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 000000000..9c3b2f930 --- /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 createState() => + _AddCustomSolanaTokenViewState(); +} + +class _AddCustomSolanaTokenViewState + extends ConsumerState { + 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 _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 = { + ...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( + 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( + 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( + 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()!.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 e86182bc2..6197a33b8 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 { 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 { 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 { 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( - context: context, - builder: (context) => Container( - color: Theme.of(context).extension()!.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 { } } + /// Navigate to add custom Solana token view and handle the result. + Future _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(); From 0329dc86b9e38b0fb52461aae701b2fc7bcd7f5b Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 18 Nov 2025 19:41:23 -0600 Subject: [PATCH 50/80] feat(spl): add route for AddCustomSolanaTokenView --- lib/route_generator.dart | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/route_generator.dart b/lib/route_generator.dart index 34c0f9694..2fbbe06eb 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -29,6 +29,7 @@ import 'models/keys/view_only_wallet_data.dart'; import 'models/paynym/paynym_account_lite.dart'; import 'models/send_view_auto_fill_data.dart'; import 'pages/add_wallet_views/add_token_view/add_custom_token_view.dart'; +import 'pages/add_wallet_views/add_token_view/add_custom_solana_token_view.dart'; import 'pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart'; import 'pages/add_wallet_views/add_wallet_view/add_wallet_view.dart'; import 'pages/add_wallet_views/create_or_restore_wallet_view/create_or_restore_wallet_view.dart'; @@ -396,6 +397,13 @@ class RouteGenerator { settings: RouteSettings(name: settings.name), ); + case AddCustomSolanaTokenView.routeName: + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => const AddCustomSolanaTokenView(), + settings: RouteSettings(name: settings.name), + ); + case WalletsOverview.routeName: if (args is CryptoCurrency) { return getRoute( From 9a48267a44b4b0132b108b648031d5b7a30ee4de Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 18 Nov 2025 23:26:03 -0600 Subject: [PATCH 51/80] feat(spl): mobile custom token flow --- .../edit_wallet_tokens_view.dart | 58 ++++++++++++------- .../sub_widgets/add_token_list.dart | 1 + lib/route_generator.dart | 3 +- 3 files changed, 41 insertions(+), 21 deletions(-) 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 6197a33b8..2d4f869bb 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 @@ -118,27 +118,26 @@ class _EditWalletTokensViewState extends ConsumerState { // 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); + // Separate selected tokens into default and custom. + final defaultTokenMints = DefaultSplTokens.list.map((e) => e.address).toSet(); + final selectedDefaultTokens = selectedTokens.where( + (mint) => defaultTokenMints.contains(mint), + ).toSet(); + final selectedCustomTokens = selectedTokens.where( + (mint) => !defaultTokenMints.contains(mint), + ).toSet(); + // Update default token mint addresses. await walletInfo.updateSolanaTokenMintAddresses( - newMintAddresses: selectedTokens.toSet(), + newMintAddresses: selectedDefaultTokens, 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, - ); - } + // Update custom token mint addresses. + await walletInfo.updateSolanaCustomTokenMintAddresses( + newMintAddresses: selectedCustomTokens, + isar: MainDB.instance.isar, + ); // Log selected tokens and verify ownership. debugPrint('===== SOLANA TOKEN OWNERSHIP CHECK ====='); @@ -304,7 +303,10 @@ class _EditWalletTokensViewState extends ConsumerState { } else { final result = await Navigator.of( context, - ).pushNamed(AddCustomSolanaTokenView.routeName); + ).pushNamed( + AddCustomSolanaTokenView.routeName, + arguments: widget.walletId, + ); token = result as SplToken?; } @@ -347,9 +349,25 @@ class _EditWalletTokensViewState extends ConsumerState { // 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))); + // Load both default and custom Solana tokens. + final defaultSplTokens = DefaultSplTokens.list; + tokenEntities.addAll(defaultSplTokens.map((e) => AddTokenListElementData(e))); + + // Load custom tokens from database + final customSplTokens = MainDB.instance.getSplTokens().findAllSync(); + + // Deduplicate: only add custom tokens that aren't already in defaults. + final seenAddresses = { + ...defaultSplTokens.map((e) => e.address), + ...tokenEntities.map((e) => e.token.address), + }; + + for (final token in customSplTokens) { + if (!seenAddresses.contains(token.address)) { + tokenEntities.add(AddTokenListElementData(token)); + seenAddresses.add(token.address); + } + } } else { // Load Ethereum tokens (default behavior for Ethereum wallets). final contracts = MainDB.instance diff --git a/lib/pages/add_wallet_views/add_token_view/sub_widgets/add_token_list.dart b/lib/pages/add_wallet_views/add_token_view/sub_widgets/add_token_list.dart index 23bfb36c2..b479a62aa 100644 --- a/lib/pages/add_wallet_views/add_token_view/sub_widgets/add_token_list.dart +++ b/lib/pages/add_wallet_views/add_token_view/sub_widgets/add_token_list.dart @@ -46,6 +46,7 @@ class AddTokenList extends StatelessWidget { child: Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: AddTokenListElement( + key: Key(items[index].token.address), data: items[index], ), ), diff --git a/lib/route_generator.dart b/lib/route_generator.dart index 2fbbe06eb..295035bd1 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -398,9 +398,10 @@ class RouteGenerator { ); case AddCustomSolanaTokenView.routeName: + final walletId = args is String ? args : null; return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, - builder: (_) => const AddCustomSolanaTokenView(), + builder: (_) => AddCustomSolanaTokenView(walletId: walletId), settings: RouteSettings(name: settings.name), ); From efcfd2f3b467020219fad1d21538bcd650dcd26a Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 19 Nov 2025 10:28:14 -0600 Subject: [PATCH 52/80] fix(spl): revert changes to stack_theme.dart --- lib/models/isar/stack_theme.dart | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/models/isar/stack_theme.dart b/lib/models/isar/stack_theme.dart index 68e0f047b..476494d92 100644 --- a/lib/models/isar/stack_theme.dart +++ b/lib/models/isar/stack_theme.dart @@ -1944,7 +1944,6 @@ class ThemeAssets implements IThemeAssets { late final String namecoin; late final String particl; late final String mimblewimblecoin; - late final String solana; late final String bitcoinImage; late final String bitcoincashImage; late final String dogecoinImage; @@ -2012,7 +2011,6 @@ class ThemeAssets implements IThemeAssets { ..wownero = "$themeId/assets/${json["wownero"] as String}" ..namecoin = "$themeId/assets/${json["namecoin"] as String}" ..particl = "$themeId/assets/${json["particl"] as String}" - ..solana = "$themeId/assets/${json["solana"] as String}" ..bitcoinImage = "$themeId/assets/${json["bitcoin_image"] as String}" ..bitcoincashImage = "$themeId/assets/${json["bitcoincash_image"] as String}" From 955482981a137e2fd255281b870756bdb904d612 Mon Sep 17 00:00:00 2001 From: julian Date: Wed, 19 Nov 2025 13:24:45 -0600 Subject: [PATCH 53/80] Amount and Balance QoL constructors --- lib/models/balance.dart | 31 ++++++++-------- lib/utilities/amount/amount.dart | 62 ++++++++++---------------------- 2 files changed, 34 insertions(+), 59 deletions(-) diff --git a/lib/models/balance.dart b/lib/models/balance.dart index 968041254..4fc728ade 100644 --- a/lib/models/balance.dart +++ b/lib/models/balance.dart @@ -19,19 +19,18 @@ class Balance { final Amount blockedTotal; final Amount pendingSpendable; - Balance({ + const Balance({ required this.total, required this.spendable, required this.blockedTotal, required this.pendingSpendable, }); - factory Balance.zeroFor({required CryptoCurrency currency}) { - final amount = Amount( - rawValue: BigInt.zero, - fractionDigits: currency.fractionDigits, - ); + factory Balance.zeroFor({required CryptoCurrency currency}) => + .zeroWith(fractionDigits: currency.fractionDigits); + factory Balance.zeroWith({required int fractionDigits}) { + final amount = Amount.zeroWith(fractionDigits: fractionDigits); return Balance( total: amount, spendable: amount, @@ -41,11 +40,11 @@ class Balance { } String toJsonIgnoreCoin() => jsonEncode({ - "total": total.toJsonString(), - "spendable": spendable.toJsonString(), - "blockedTotal": blockedTotal.toJsonString(), - "pendingSpendable": pendingSpendable.toJsonString(), - }); + "total": total.toJsonString(), + "spendable": spendable.toJsonString(), + "blockedTotal": blockedTotal.toJsonString(), + "pendingSpendable": pendingSpendable.toJsonString(), + }); // need to fall back to parsing from int due to cached balances being previously // stored as int values instead of Amounts @@ -82,11 +81,11 @@ class Balance { } Map toMap() => { - "total": total, - "spendable": spendable, - "blockedTotal": blockedTotal, - "pendingSpendable": pendingSpendable, - }; + "total": total, + "spendable": spendable, + "blockedTotal": blockedTotal, + "pendingSpendable": pendingSpendable, + }; @override String toString() { diff --git a/lib/utilities/amount/amount.dart b/lib/utilities/amount/amount.dart index a1a68576f..b31d87d7a 100644 --- a/lib/utilities/amount/amount.dart +++ b/lib/utilities/amount/amount.dart @@ -15,31 +15,24 @@ import 'package:decimal/decimal.dart'; import '../util.dart'; class Amount { - Amount({ - required BigInt rawValue, - required this.fractionDigits, - }) : assert(fractionDigits >= 0), - _value = rawValue; + const Amount({required BigInt rawValue, required this.fractionDigits}) + : assert(fractionDigits >= 0), + _value = rawValue; /// special zero case with [fractionDigits] set to 0 - static Amount get zero => Amount( - rawValue: BigInt.zero, - fractionDigits: 0, - ); + static Amount get zero => .zeroWith(fractionDigits: 0); - Amount.zeroWith({required this.fractionDigits}) - : assert(fractionDigits >= 0), - _value = BigInt.zero; + Amount.zeroWith({required int fractionDigits}) + : this(rawValue: BigInt.from(0), fractionDigits: fractionDigits); /// truncate decimal value to [fractionDigits] places - Amount.fromDecimal(Decimal amount, {required this.fractionDigits}) - : assert(fractionDigits >= 0), - _value = amount.shift(fractionDigits).toBigInt(); - - static Amount? tryParseFiatString( - String value, { - required String locale, - }) { + Amount.fromDecimal(Decimal amount, {required int fractionDigits}) + : this( + rawValue: amount.shift(fractionDigits).toBigInt(), + fractionDigits: fractionDigits, + ); + + static Amount? tryParseFiatString(String value, {required String locale}) { final parts = value.split(" "); if (parts.first.isEmpty) { @@ -98,9 +91,7 @@ class Amount { return jsonEncode(toMap()); } - String fiatString({ - required String locale, - }) { + String fiatString({required String locale}) { final wholeNumber = decimal.truncate(); // get number symbols for decimal place and group separator @@ -172,10 +163,7 @@ class Amount { "fractionDigits do not match: this=$this, other=$other", ); } - return Amount( - rawValue: raw + other.raw, - fractionDigits: fractionDigits, - ); + return Amount(rawValue: raw + other.raw, fractionDigits: fractionDigits); } Amount operator -(Amount other) { @@ -184,10 +172,7 @@ class Amount { "fractionDigits do not match: this=$this, other=$other", ); } - return Amount( - rawValue: raw - other.raw, - fractionDigits: fractionDigits, - ); + return Amount(rawValue: raw - other.raw, fractionDigits: fractionDigits); } Amount operator *(Amount other) { @@ -196,10 +181,7 @@ class Amount { "fractionDigits do not match: this=$this, other=$other", ); } - return Amount( - rawValue: raw * other.raw, - fractionDigits: fractionDigits, - ); + return Amount(rawValue: raw * other.raw, fractionDigits: fractionDigits); } // =========================================================================== @@ -226,18 +208,12 @@ class Amount { extension DecimalAmountExt on Decimal { Amount toAmount({required int fractionDigits}) { - return Amount.fromDecimal( - this, - fractionDigits: fractionDigits, - ); + return Amount.fromDecimal(this, fractionDigits: fractionDigits); } } extension IntAmountExtension on int { Amount toAmountAsRaw({required int fractionDigits}) { - return Amount( - rawValue: BigInt.from(this), - fractionDigits: fractionDigits, - ); + return Amount(rawValue: BigInt.from(this), fractionDigits: fractionDigits); } } From 5832233c3cab9bc72c29c67fb800b412a416e43d Mon Sep 17 00:00:00 2001 From: julian Date: Wed, 19 Nov 2025 14:17:25 -0600 Subject: [PATCH 54/80] housekeeping (build runner, etc) --- .gitignore | 4 - .../models/blockchain_data/transaction.g.dart | 2 + .../blockchain_data/v2/transaction_v2.g.dart | 2 + .../isar/models/solana/spl_token.g.dart | 1482 +++++++++++++++++ lib/themes/coin_icon_provider.dart | 7 +- .../models/wallet_solana_token_info.g.dart | 1433 ++++++++++++++++ test/cached_electrumx_test.mocks.dart | 25 + .../pages/send_view/send_view_test.mocks.dart | 25 + .../exchange/exchange_view_test.mocks.dart | 25 + .../managed_favorite_test.mocks.dart | 25 + .../node_options_sheet_test.mocks.dart | 38 +- .../transaction_card_test.mocks.dart | 75 + 12 files changed, 3125 insertions(+), 18 deletions(-) create mode 100644 lib/models/isar/models/solana/spl_token.g.dart create mode 100644 lib/wallets/isar/models/wallet_solana_token_info.g.dart diff --git a/.gitignore b/.gitignore index f373c61fe..05f3ede79 100644 --- a/.gitignore +++ b/.gitignore @@ -70,10 +70,6 @@ secp256k1.dll /lib/app_config.g.dart /android/app/src/main/app_icon-playstore.png -# Dart generated files (Freezed, Riverpod, GoRouter etc..) -lib/**/*.g.dart -lib/**/*.freezed.dart - ## other generated project files pubspec.yaml diff --git a/lib/models/isar/models/blockchain_data/transaction.g.dart b/lib/models/isar/models/blockchain_data/transaction.g.dart index aa41d834b..c87673a99 100644 --- a/lib/models/isar/models/blockchain_data/transaction.g.dart +++ b/lib/models/isar/models/blockchain_data/transaction.g.dart @@ -356,6 +356,7 @@ const _TransactionsubTypeEnumValueMap = { 'sparkSpend': 7, 'ordinal': 8, 'mweb': 9, + 'splToken': 10, }; const _TransactionsubTypeValueEnumMap = { 0: TransactionSubType.none, @@ -368,6 +369,7 @@ const _TransactionsubTypeValueEnumMap = { 7: TransactionSubType.sparkSpend, 8: TransactionSubType.ordinal, 9: TransactionSubType.mweb, + 10: TransactionSubType.splToken, }; const _TransactiontypeEnumValueMap = { 'outgoing': 0, diff --git a/lib/models/isar/models/blockchain_data/v2/transaction_v2.g.dart b/lib/models/isar/models/blockchain_data/v2/transaction_v2.g.dart index 1335d783a..c604f36f7 100644 --- a/lib/models/isar/models/blockchain_data/v2/transaction_v2.g.dart +++ b/lib/models/isar/models/blockchain_data/v2/transaction_v2.g.dart @@ -378,6 +378,7 @@ const _TransactionV2subTypeEnumValueMap = { 'sparkSpend': 7, 'ordinal': 8, 'mweb': 9, + 'splToken': 10, }; const _TransactionV2subTypeValueEnumMap = { 0: TransactionSubType.none, @@ -390,6 +391,7 @@ const _TransactionV2subTypeValueEnumMap = { 7: TransactionSubType.sparkSpend, 8: TransactionSubType.ordinal, 9: TransactionSubType.mweb, + 10: TransactionSubType.splToken, }; const _TransactionV2typeEnumValueMap = { 'outgoing': 0, diff --git a/lib/models/isar/models/solana/spl_token.g.dart b/lib/models/isar/models/solana/spl_token.g.dart new file mode 100644 index 000000000..317e34bf2 --- /dev/null +++ b/lib/models/isar/models/solana/spl_token.g.dart @@ -0,0 +1,1482 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'spl_token.dart'; + +// ************************************************************************** +// IsarCollectionGenerator +// ************************************************************************** + +// coverage:ignore-file +// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types + +extension GetSplTokenCollection on Isar { + IsarCollection get splTokens => this.collection(); +} + +const SplTokenSchema = CollectionSchema( + name: r'SplToken', + id: 8546297717864199659, + properties: { + r'address': PropertySchema(id: 0, name: r'address', type: IsarType.string), + r'decimals': PropertySchema(id: 1, name: r'decimals', type: IsarType.long), + r'logoUri': PropertySchema(id: 2, name: r'logoUri', type: IsarType.string), + r'metadataAddress': PropertySchema( + id: 3, + name: r'metadataAddress', + type: IsarType.string, + ), + r'name': PropertySchema(id: 4, name: r'name', type: IsarType.string), + r'symbol': PropertySchema(id: 5, name: r'symbol', type: IsarType.string), + }, + + estimateSize: _splTokenEstimateSize, + serialize: _splTokenSerialize, + deserialize: _splTokenDeserialize, + deserializeProp: _splTokenDeserializeProp, + idName: r'id', + indexes: { + r'address': IndexSchema( + id: -259407546592846288, + name: r'address', + unique: true, + replace: true, + properties: [ + IndexPropertySchema( + name: r'address', + type: IndexType.hash, + caseSensitive: true, + ), + ], + ), + }, + links: {}, + embeddedSchemas: {}, + + getId: _splTokenGetId, + getLinks: _splTokenGetLinks, + attach: _splTokenAttach, + version: '3.3.0-dev.2', +); + +int _splTokenEstimateSize( + SplToken object, + List offsets, + Map> allOffsets, +) { + var bytesCount = offsets.last; + bytesCount += 3 + object.address.length * 3; + { + final value = object.logoUri; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } + { + final value = object.metadataAddress; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } + bytesCount += 3 + object.name.length * 3; + bytesCount += 3 + object.symbol.length * 3; + return bytesCount; +} + +void _splTokenSerialize( + SplToken object, + IsarWriter writer, + List offsets, + Map> allOffsets, +) { + writer.writeString(offsets[0], object.address); + writer.writeLong(offsets[1], object.decimals); + writer.writeString(offsets[2], object.logoUri); + writer.writeString(offsets[3], object.metadataAddress); + writer.writeString(offsets[4], object.name); + writer.writeString(offsets[5], object.symbol); +} + +SplToken _splTokenDeserialize( + Id id, + IsarReader reader, + List offsets, + Map> allOffsets, +) { + final object = SplToken( + address: reader.readString(offsets[0]), + decimals: reader.readLong(offsets[1]), + logoUri: reader.readStringOrNull(offsets[2]), + metadataAddress: reader.readStringOrNull(offsets[3]), + name: reader.readString(offsets[4]), + symbol: reader.readString(offsets[5]), + ); + object.id = id; + return object; +} + +P _splTokenDeserializeProp

( + IsarReader reader, + int propertyId, + int offset, + Map> allOffsets, +) { + switch (propertyId) { + case 0: + return (reader.readString(offset)) as P; + case 1: + return (reader.readLong(offset)) as P; + case 2: + return (reader.readStringOrNull(offset)) as P; + case 3: + return (reader.readStringOrNull(offset)) as P; + case 4: + return (reader.readString(offset)) as P; + case 5: + return (reader.readString(offset)) as P; + default: + throw IsarError('Unknown property with id $propertyId'); + } +} + +Id _splTokenGetId(SplToken object) { + return object.id; +} + +List> _splTokenGetLinks(SplToken object) { + return []; +} + +void _splTokenAttach(IsarCollection col, Id id, SplToken object) { + object.id = id; +} + +extension SplTokenByIndex on IsarCollection { + Future getByAddress(String address) { + return getByIndex(r'address', [address]); + } + + SplToken? getByAddressSync(String address) { + return getByIndexSync(r'address', [address]); + } + + Future deleteByAddress(String address) { + return deleteByIndex(r'address', [address]); + } + + bool deleteByAddressSync(String address) { + return deleteByIndexSync(r'address', [address]); + } + + Future> getAllByAddress(List addressValues) { + final values = addressValues.map((e) => [e]).toList(); + return getAllByIndex(r'address', values); + } + + List getAllByAddressSync(List addressValues) { + final values = addressValues.map((e) => [e]).toList(); + return getAllByIndexSync(r'address', values); + } + + Future deleteAllByAddress(List addressValues) { + final values = addressValues.map((e) => [e]).toList(); + return deleteAllByIndex(r'address', values); + } + + int deleteAllByAddressSync(List addressValues) { + final values = addressValues.map((e) => [e]).toList(); + return deleteAllByIndexSync(r'address', values); + } + + Future putByAddress(SplToken object) { + return putByIndex(r'address', object); + } + + Id putByAddressSync(SplToken object, {bool saveLinks = true}) { + return putByIndexSync(r'address', object, saveLinks: saveLinks); + } + + Future> putAllByAddress(List objects) { + return putAllByIndex(r'address', objects); + } + + List putAllByAddressSync( + List objects, { + bool saveLinks = true, + }) { + return putAllByIndexSync(r'address', objects, saveLinks: saveLinks); + } +} + +extension SplTokenQueryWhereSort on QueryBuilder { + QueryBuilder anyId() { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(const IdWhereClause.any()); + }); + } +} + +extension SplTokenQueryWhere on QueryBuilder { + QueryBuilder idEqualTo(Id id) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between(lower: id, upper: id)); + }); + } + + QueryBuilder idNotEqualTo(Id id) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: false), + ) + .addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: false), + ); + } else { + return query + .addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: false), + ) + .addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: false), + ); + } + }); + } + + QueryBuilder idGreaterThan( + Id id, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: include), + ); + }); + } + + QueryBuilder idLessThan( + Id id, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: include), + ); + }); + } + + QueryBuilder idBetween( + Id lowerId, + Id upperId, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.between( + lower: lowerId, + includeLower: includeLower, + upper: upperId, + includeUpper: includeUpper, + ), + ); + }); + } + + QueryBuilder addressEqualTo( + String address, + ) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IndexWhereClause.equalTo(indexName: r'address', value: [address]), + ); + }); + } + + QueryBuilder addressNotEqualTo( + String address, + ) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause( + IndexWhereClause.between( + indexName: r'address', + lower: [], + upper: [address], + includeUpper: false, + ), + ) + .addWhereClause( + IndexWhereClause.between( + indexName: r'address', + lower: [address], + includeLower: false, + upper: [], + ), + ); + } else { + return query + .addWhereClause( + IndexWhereClause.between( + indexName: r'address', + lower: [address], + includeLower: false, + upper: [], + ), + ) + .addWhereClause( + IndexWhereClause.between( + indexName: r'address', + lower: [], + upper: [address], + includeUpper: false, + ), + ); + } + }); + } +} + +extension SplTokenQueryFilter + on QueryBuilder { + QueryBuilder addressEqualTo( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'address', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder addressGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'address', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder addressLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'address', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder addressBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'address', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder addressStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.startsWith( + property: r'address', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder addressEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.endsWith( + property: r'address', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder addressContains( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.contains( + property: r'address', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder addressMatches( + String pattern, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.matches( + property: r'address', + wildcard: pattern, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder addressIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'address', value: ''), + ); + }); + } + + QueryBuilder addressIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan(property: r'address', value: ''), + ); + }); + } + + QueryBuilder decimalsEqualTo( + int value, + ) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'decimals', value: value), + ); + }); + } + + QueryBuilder decimalsGreaterThan( + int value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'decimals', + value: value, + ), + ); + }); + } + + QueryBuilder decimalsLessThan( + int value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'decimals', + value: value, + ), + ); + }); + } + + QueryBuilder decimalsBetween( + int lower, + int upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'decimals', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + ), + ); + }); + } + + QueryBuilder idEqualTo(Id value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'id', value: value), + ); + }); + } + + QueryBuilder idGreaterThan( + Id value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'id', + value: value, + ), + ); + }); + } + + QueryBuilder idLessThan( + Id value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'id', + value: value, + ), + ); + }); + } + + QueryBuilder idBetween( + Id lower, + Id upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'id', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + ), + ); + }); + } + + QueryBuilder logoUriIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + const FilterCondition.isNull(property: r'logoUri'), + ); + }); + } + + QueryBuilder logoUriIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + const FilterCondition.isNotNull(property: r'logoUri'), + ); + }); + } + + QueryBuilder logoUriEqualTo( + String? value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'logoUri', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder logoUriGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'logoUri', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder logoUriLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'logoUri', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder logoUriBetween( + String? lower, + String? upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'logoUri', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder logoUriStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.startsWith( + property: r'logoUri', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder logoUriEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.endsWith( + property: r'logoUri', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder logoUriContains( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.contains( + property: r'logoUri', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder logoUriMatches( + String pattern, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.matches( + property: r'logoUri', + wildcard: pattern, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder logoUriIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'logoUri', value: ''), + ); + }); + } + + QueryBuilder logoUriIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan(property: r'logoUri', value: ''), + ); + }); + } + + QueryBuilder + metadataAddressIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + const FilterCondition.isNull(property: r'metadataAddress'), + ); + }); + } + + QueryBuilder + metadataAddressIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + const FilterCondition.isNotNull(property: r'metadataAddress'), + ); + }); + } + + QueryBuilder + metadataAddressEqualTo(String? value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'metadataAddress', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + metadataAddressGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'metadataAddress', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + metadataAddressLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'metadataAddress', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + metadataAddressBetween( + String? lower, + String? upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'metadataAddress', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + metadataAddressStartsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.startsWith( + property: r'metadataAddress', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + metadataAddressEndsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.endsWith( + property: r'metadataAddress', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + metadataAddressContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.contains( + property: r'metadataAddress', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + metadataAddressMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.matches( + property: r'metadataAddress', + wildcard: pattern, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + metadataAddressIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'metadataAddress', value: ''), + ); + }); + } + + QueryBuilder + metadataAddressIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan(property: r'metadataAddress', value: ''), + ); + }); + } + + QueryBuilder nameEqualTo( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'name', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder nameGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'name', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder nameLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'name', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder nameBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'name', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder nameStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.startsWith( + property: r'name', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder nameEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.endsWith( + property: r'name', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder nameContains( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.contains( + property: r'name', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder nameMatches( + String pattern, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.matches( + property: r'name', + wildcard: pattern, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder nameIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'name', value: ''), + ); + }); + } + + QueryBuilder nameIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan(property: r'name', value: ''), + ); + }); + } + + QueryBuilder symbolEqualTo( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'symbol', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder symbolGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'symbol', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder symbolLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'symbol', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder symbolBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'symbol', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder symbolStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.startsWith( + property: r'symbol', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder symbolEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.endsWith( + property: r'symbol', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder symbolContains( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.contains( + property: r'symbol', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder symbolMatches( + String pattern, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.matches( + property: r'symbol', + wildcard: pattern, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder symbolIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'symbol', value: ''), + ); + }); + } + + QueryBuilder symbolIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan(property: r'symbol', value: ''), + ); + }); + } +} + +extension SplTokenQueryObject + on QueryBuilder {} + +extension SplTokenQueryLinks + on QueryBuilder {} + +extension SplTokenQuerySortBy on QueryBuilder { + QueryBuilder sortByAddress() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'address', Sort.asc); + }); + } + + QueryBuilder sortByAddressDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'address', Sort.desc); + }); + } + + QueryBuilder sortByDecimals() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'decimals', Sort.asc); + }); + } + + QueryBuilder sortByDecimalsDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'decimals', Sort.desc); + }); + } + + QueryBuilder sortByLogoUri() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'logoUri', Sort.asc); + }); + } + + QueryBuilder sortByLogoUriDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'logoUri', Sort.desc); + }); + } + + QueryBuilder sortByMetadataAddress() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'metadataAddress', Sort.asc); + }); + } + + QueryBuilder sortByMetadataAddressDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'metadataAddress', Sort.desc); + }); + } + + QueryBuilder sortByName() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'name', Sort.asc); + }); + } + + QueryBuilder sortByNameDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'name', Sort.desc); + }); + } + + QueryBuilder sortBySymbol() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'symbol', Sort.asc); + }); + } + + QueryBuilder sortBySymbolDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'symbol', Sort.desc); + }); + } +} + +extension SplTokenQuerySortThenBy + on QueryBuilder { + QueryBuilder thenByAddress() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'address', Sort.asc); + }); + } + + QueryBuilder thenByAddressDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'address', Sort.desc); + }); + } + + QueryBuilder thenByDecimals() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'decimals', Sort.asc); + }); + } + + QueryBuilder thenByDecimalsDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'decimals', Sort.desc); + }); + } + + QueryBuilder thenById() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'id', Sort.asc); + }); + } + + QueryBuilder thenByIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'id', Sort.desc); + }); + } + + QueryBuilder thenByLogoUri() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'logoUri', Sort.asc); + }); + } + + QueryBuilder thenByLogoUriDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'logoUri', Sort.desc); + }); + } + + QueryBuilder thenByMetadataAddress() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'metadataAddress', Sort.asc); + }); + } + + QueryBuilder thenByMetadataAddressDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'metadataAddress', Sort.desc); + }); + } + + QueryBuilder thenByName() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'name', Sort.asc); + }); + } + + QueryBuilder thenByNameDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'name', Sort.desc); + }); + } + + QueryBuilder thenBySymbol() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'symbol', Sort.asc); + }); + } + + QueryBuilder thenBySymbolDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'symbol', Sort.desc); + }); + } +} + +extension SplTokenQueryWhereDistinct + on QueryBuilder { + QueryBuilder distinctByAddress({ + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'address', caseSensitive: caseSensitive); + }); + } + + QueryBuilder distinctByDecimals() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'decimals'); + }); + } + + QueryBuilder distinctByLogoUri({ + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'logoUri', caseSensitive: caseSensitive); + }); + } + + QueryBuilder distinctByMetadataAddress({ + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy( + r'metadataAddress', + caseSensitive: caseSensitive, + ); + }); + } + + QueryBuilder distinctByName({ + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'name', caseSensitive: caseSensitive); + }); + } + + QueryBuilder distinctBySymbol({ + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'symbol', caseSensitive: caseSensitive); + }); + } +} + +extension SplTokenQueryProperty + on QueryBuilder { + QueryBuilder idProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'id'); + }); + } + + QueryBuilder addressProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'address'); + }); + } + + QueryBuilder decimalsProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'decimals'); + }); + } + + QueryBuilder logoUriProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'logoUri'); + }); + } + + QueryBuilder metadataAddressProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'metadataAddress'); + }); + } + + QueryBuilder nameProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'name'); + }); + } + + QueryBuilder symbolProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'symbol'); + }); + } +} diff --git a/lib/themes/coin_icon_provider.dart b/lib/themes/coin_icon_provider.dart index 572adb10c..a732c69c1 100644 --- a/lib/themes/coin_icon_provider.dart +++ b/lib/themes/coin_icon_provider.dart @@ -9,9 +9,10 @@ */ import 'package:flutter_riverpod/flutter_riverpod.dart'; + import '../models/isar/stack_theme.dart'; -import 'theme_providers.dart'; import '../wallets/crypto_currency/crypto_currency.dart'; +import 'theme_providers.dart'; final coinIconProvider = Provider.family((ref, coin) { final assets = ref.watch(themeAssetsProvider); @@ -28,8 +29,6 @@ final coinIconProvider = Provider.family((ref, coin) { return assets.dogecoin; case const (Epiccash): return assets.epicCash; - case const (Mimblewimblecoin): - return assets.mimblewimblecoin; case const (Firo): return assets.firo; case const (Monero): @@ -42,8 +41,6 @@ final coinIconProvider = Provider.family((ref, coin) { return assets.particl; case const (Ethereum): return assets.ethereum; - case const (Solana): - return assets.solana; default: return assets.stackIcon; } diff --git a/lib/wallets/isar/models/wallet_solana_token_info.g.dart b/lib/wallets/isar/models/wallet_solana_token_info.g.dart new file mode 100644 index 000000000..bd8370081 --- /dev/null +++ b/lib/wallets/isar/models/wallet_solana_token_info.g.dart @@ -0,0 +1,1433 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'wallet_solana_token_info.dart'; + +// ************************************************************************** +// IsarCollectionGenerator +// ************************************************************************** + +// coverage:ignore-file +// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types + +extension GetWalletSolanaTokenInfoCollection on Isar { + IsarCollection get walletSolanaTokenInfo => + this.collection(); +} + +const WalletSolanaTokenInfoSchema = CollectionSchema( + name: r'WalletSolanaTokenInfo', + id: 7293372558936095532, + properties: { + r'cachedBalanceJsonString': PropertySchema( + id: 0, + name: r'cachedBalanceJsonString', + type: IsarType.string, + ), + r'tokenAddress': PropertySchema( + id: 1, + name: r'tokenAddress', + type: IsarType.string, + ), + r'tokenFractionDigits': PropertySchema( + id: 2, + name: r'tokenFractionDigits', + type: IsarType.long, + ), + r'walletId': PropertySchema( + id: 3, + name: r'walletId', + type: IsarType.string, + ), + }, + + estimateSize: _walletSolanaTokenInfoEstimateSize, + serialize: _walletSolanaTokenInfoSerialize, + deserialize: _walletSolanaTokenInfoDeserialize, + deserializeProp: _walletSolanaTokenInfoDeserializeProp, + idName: r'id', + indexes: { + r'walletId_tokenAddress': IndexSchema( + id: -7747794843092592407, + name: r'walletId_tokenAddress', + unique: true, + replace: false, + properties: [ + IndexPropertySchema( + name: r'walletId', + type: IndexType.hash, + caseSensitive: true, + ), + IndexPropertySchema( + name: r'tokenAddress', + type: IndexType.hash, + caseSensitive: true, + ), + ], + ), + }, + links: {}, + embeddedSchemas: {}, + + getId: _walletSolanaTokenInfoGetId, + getLinks: _walletSolanaTokenInfoGetLinks, + attach: _walletSolanaTokenInfoAttach, + version: '3.3.0-dev.2', +); + +int _walletSolanaTokenInfoEstimateSize( + WalletSolanaTokenInfo object, + List offsets, + Map> allOffsets, +) { + var bytesCount = offsets.last; + { + final value = object.cachedBalanceJsonString; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } + bytesCount += 3 + object.tokenAddress.length * 3; + bytesCount += 3 + object.walletId.length * 3; + return bytesCount; +} + +void _walletSolanaTokenInfoSerialize( + WalletSolanaTokenInfo object, + IsarWriter writer, + List offsets, + Map> allOffsets, +) { + writer.writeString(offsets[0], object.cachedBalanceJsonString); + writer.writeString(offsets[1], object.tokenAddress); + writer.writeLong(offsets[2], object.tokenFractionDigits); + writer.writeString(offsets[3], object.walletId); +} + +WalletSolanaTokenInfo _walletSolanaTokenInfoDeserialize( + Id id, + IsarReader reader, + List offsets, + Map> allOffsets, +) { + final object = WalletSolanaTokenInfo( + cachedBalanceJsonString: reader.readStringOrNull(offsets[0]), + tokenAddress: reader.readString(offsets[1]), + tokenFractionDigits: reader.readLong(offsets[2]), + walletId: reader.readString(offsets[3]), + ); + object.id = id; + return object; +} + +P _walletSolanaTokenInfoDeserializeProp

( + IsarReader reader, + int propertyId, + int offset, + Map> allOffsets, +) { + switch (propertyId) { + case 0: + return (reader.readStringOrNull(offset)) as P; + case 1: + return (reader.readString(offset)) as P; + case 2: + return (reader.readLong(offset)) as P; + case 3: + return (reader.readString(offset)) as P; + default: + throw IsarError('Unknown property with id $propertyId'); + } +} + +Id _walletSolanaTokenInfoGetId(WalletSolanaTokenInfo object) { + return object.id; +} + +List> _walletSolanaTokenInfoGetLinks( + WalletSolanaTokenInfo object, +) { + return []; +} + +void _walletSolanaTokenInfoAttach( + IsarCollection col, + Id id, + WalletSolanaTokenInfo object, +) { + object.id = id; +} + +extension WalletSolanaTokenInfoByIndex + on IsarCollection { + Future getByWalletIdTokenAddress( + String walletId, + String tokenAddress, + ) { + return getByIndex(r'walletId_tokenAddress', [walletId, tokenAddress]); + } + + WalletSolanaTokenInfo? getByWalletIdTokenAddressSync( + String walletId, + String tokenAddress, + ) { + return getByIndexSync(r'walletId_tokenAddress', [walletId, tokenAddress]); + } + + Future deleteByWalletIdTokenAddress( + String walletId, + String tokenAddress, + ) { + return deleteByIndex(r'walletId_tokenAddress', [walletId, tokenAddress]); + } + + bool deleteByWalletIdTokenAddressSync(String walletId, String tokenAddress) { + return deleteByIndexSync(r'walletId_tokenAddress', [ + walletId, + tokenAddress, + ]); + } + + Future> getAllByWalletIdTokenAddress( + List walletIdValues, + List tokenAddressValues, + ) { + final len = walletIdValues.length; + assert( + tokenAddressValues.length == len, + 'All index values must have the same length', + ); + final values = >[]; + for (var i = 0; i < len; i++) { + values.add([walletIdValues[i], tokenAddressValues[i]]); + } + + return getAllByIndex(r'walletId_tokenAddress', values); + } + + List getAllByWalletIdTokenAddressSync( + List walletIdValues, + List tokenAddressValues, + ) { + final len = walletIdValues.length; + assert( + tokenAddressValues.length == len, + 'All index values must have the same length', + ); + final values = >[]; + for (var i = 0; i < len; i++) { + values.add([walletIdValues[i], tokenAddressValues[i]]); + } + + return getAllByIndexSync(r'walletId_tokenAddress', values); + } + + Future deleteAllByWalletIdTokenAddress( + List walletIdValues, + List tokenAddressValues, + ) { + final len = walletIdValues.length; + assert( + tokenAddressValues.length == len, + 'All index values must have the same length', + ); + final values = >[]; + for (var i = 0; i < len; i++) { + values.add([walletIdValues[i], tokenAddressValues[i]]); + } + + return deleteAllByIndex(r'walletId_tokenAddress', values); + } + + int deleteAllByWalletIdTokenAddressSync( + List walletIdValues, + List tokenAddressValues, + ) { + final len = walletIdValues.length; + assert( + tokenAddressValues.length == len, + 'All index values must have the same length', + ); + final values = >[]; + for (var i = 0; i < len; i++) { + values.add([walletIdValues[i], tokenAddressValues[i]]); + } + + return deleteAllByIndexSync(r'walletId_tokenAddress', values); + } + + Future putByWalletIdTokenAddress(WalletSolanaTokenInfo object) { + return putByIndex(r'walletId_tokenAddress', object); + } + + Id putByWalletIdTokenAddressSync( + WalletSolanaTokenInfo object, { + bool saveLinks = true, + }) { + return putByIndexSync( + r'walletId_tokenAddress', + object, + saveLinks: saveLinks, + ); + } + + Future> putAllByWalletIdTokenAddress( + List objects, + ) { + return putAllByIndex(r'walletId_tokenAddress', objects); + } + + List putAllByWalletIdTokenAddressSync( + List objects, { + bool saveLinks = true, + }) { + return putAllByIndexSync( + r'walletId_tokenAddress', + objects, + saveLinks: saveLinks, + ); + } +} + +extension WalletSolanaTokenInfoQueryWhereSort + on QueryBuilder { + QueryBuilder + anyId() { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(const IdWhereClause.any()); + }); + } +} + +extension WalletSolanaTokenInfoQueryWhere + on + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QWhereClause + > { + QueryBuilder + idEqualTo(Id id) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between(lower: id, upper: id)); + }); + } + + QueryBuilder + idNotEqualTo(Id id) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: false), + ) + .addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: false), + ); + } else { + return query + .addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: false), + ) + .addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: false), + ); + } + }); + } + + QueryBuilder + idGreaterThan(Id id, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: include), + ); + }); + } + + QueryBuilder + idLessThan(Id id, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: include), + ); + }); + } + + QueryBuilder + idBetween( + Id lowerId, + Id upperId, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.between( + lower: lowerId, + includeLower: includeLower, + upper: upperId, + includeUpper: includeUpper, + ), + ); + }); + } + + QueryBuilder + walletIdEqualToAnyTokenAddress(String walletId) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IndexWhereClause.equalTo( + indexName: r'walletId_tokenAddress', + value: [walletId], + ), + ); + }); + } + + QueryBuilder + walletIdNotEqualToAnyTokenAddress(String walletId) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause( + IndexWhereClause.between( + indexName: r'walletId_tokenAddress', + lower: [], + upper: [walletId], + includeUpper: false, + ), + ) + .addWhereClause( + IndexWhereClause.between( + indexName: r'walletId_tokenAddress', + lower: [walletId], + includeLower: false, + upper: [], + ), + ); + } else { + return query + .addWhereClause( + IndexWhereClause.between( + indexName: r'walletId_tokenAddress', + lower: [walletId], + includeLower: false, + upper: [], + ), + ) + .addWhereClause( + IndexWhereClause.between( + indexName: r'walletId_tokenAddress', + lower: [], + upper: [walletId], + includeUpper: false, + ), + ); + } + }); + } + + QueryBuilder + walletIdTokenAddressEqualTo(String walletId, String tokenAddress) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IndexWhereClause.equalTo( + indexName: r'walletId_tokenAddress', + value: [walletId, tokenAddress], + ), + ); + }); + } + + QueryBuilder + walletIdEqualToTokenAddressNotEqualTo(String walletId, String tokenAddress) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause( + IndexWhereClause.between( + indexName: r'walletId_tokenAddress', + lower: [walletId], + upper: [walletId, tokenAddress], + includeUpper: false, + ), + ) + .addWhereClause( + IndexWhereClause.between( + indexName: r'walletId_tokenAddress', + lower: [walletId, tokenAddress], + includeLower: false, + upper: [walletId], + ), + ); + } else { + return query + .addWhereClause( + IndexWhereClause.between( + indexName: r'walletId_tokenAddress', + lower: [walletId, tokenAddress], + includeLower: false, + upper: [walletId], + ), + ) + .addWhereClause( + IndexWhereClause.between( + indexName: r'walletId_tokenAddress', + lower: [walletId], + upper: [walletId, tokenAddress], + includeUpper: false, + ), + ); + } + }); + } +} + +extension WalletSolanaTokenInfoQueryFilter + on + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QFilterCondition + > { + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + cachedBalanceJsonStringIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + const FilterCondition.isNull(property: r'cachedBalanceJsonString'), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + cachedBalanceJsonStringIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + const FilterCondition.isNotNull(property: r'cachedBalanceJsonString'), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + cachedBalanceJsonStringEqualTo(String? value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'cachedBalanceJsonString', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + cachedBalanceJsonStringGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'cachedBalanceJsonString', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + cachedBalanceJsonStringLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'cachedBalanceJsonString', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + cachedBalanceJsonStringBetween( + String? lower, + String? upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'cachedBalanceJsonString', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + cachedBalanceJsonStringStartsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.startsWith( + property: r'cachedBalanceJsonString', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + cachedBalanceJsonStringEndsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.endsWith( + property: r'cachedBalanceJsonString', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + cachedBalanceJsonStringContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.contains( + property: r'cachedBalanceJsonString', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + cachedBalanceJsonStringMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.matches( + property: r'cachedBalanceJsonString', + wildcard: pattern, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + cachedBalanceJsonStringIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'cachedBalanceJsonString', + value: '', + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + cachedBalanceJsonStringIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + property: r'cachedBalanceJsonString', + value: '', + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + idEqualTo(Id value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'id', value: value), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + idGreaterThan(Id value, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'id', + value: value, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + idLessThan(Id value, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'id', + value: value, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + idBetween( + Id lower, + Id upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'id', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenAddressEqualTo(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'tokenAddress', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenAddressGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'tokenAddress', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenAddressLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'tokenAddress', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenAddressBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'tokenAddress', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenAddressStartsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.startsWith( + property: r'tokenAddress', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenAddressEndsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.endsWith( + property: r'tokenAddress', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenAddressContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.contains( + property: r'tokenAddress', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenAddressMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.matches( + property: r'tokenAddress', + wildcard: pattern, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenAddressIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'tokenAddress', value: ''), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenAddressIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan(property: r'tokenAddress', value: ''), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenFractionDigitsEqualTo(int value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'tokenFractionDigits', value: value), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenFractionDigitsGreaterThan(int value, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'tokenFractionDigits', + value: value, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenFractionDigitsLessThan(int value, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'tokenFractionDigits', + value: value, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenFractionDigitsBetween( + int lower, + int upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'tokenFractionDigits', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + walletIdEqualTo(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'walletId', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + walletIdGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'walletId', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + walletIdLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'walletId', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + walletIdBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'walletId', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + walletIdStartsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.startsWith( + property: r'walletId', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + walletIdEndsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.endsWith( + property: r'walletId', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + walletIdContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.contains( + property: r'walletId', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + walletIdMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.matches( + property: r'walletId', + wildcard: pattern, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + walletIdIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'walletId', value: ''), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + walletIdIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan(property: r'walletId', value: ''), + ); + }); + } +} + +extension WalletSolanaTokenInfoQueryObject + on + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QFilterCondition + > {} + +extension WalletSolanaTokenInfoQueryLinks + on + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QFilterCondition + > {} + +extension WalletSolanaTokenInfoQuerySortBy + on QueryBuilder { + QueryBuilder + sortByCachedBalanceJsonString() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'cachedBalanceJsonString', Sort.asc); + }); + } + + QueryBuilder + sortByCachedBalanceJsonStringDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'cachedBalanceJsonString', Sort.desc); + }); + } + + QueryBuilder + sortByTokenAddress() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'tokenAddress', Sort.asc); + }); + } + + QueryBuilder + sortByTokenAddressDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'tokenAddress', Sort.desc); + }); + } + + QueryBuilder + sortByTokenFractionDigits() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'tokenFractionDigits', Sort.asc); + }); + } + + QueryBuilder + sortByTokenFractionDigitsDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'tokenFractionDigits', Sort.desc); + }); + } + + QueryBuilder + sortByWalletId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'walletId', Sort.asc); + }); + } + + QueryBuilder + sortByWalletIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'walletId', Sort.desc); + }); + } +} + +extension WalletSolanaTokenInfoQuerySortThenBy + on QueryBuilder { + QueryBuilder + thenByCachedBalanceJsonString() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'cachedBalanceJsonString', Sort.asc); + }); + } + + QueryBuilder + thenByCachedBalanceJsonStringDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'cachedBalanceJsonString', Sort.desc); + }); + } + + QueryBuilder + thenById() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'id', Sort.asc); + }); + } + + QueryBuilder + thenByIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'id', Sort.desc); + }); + } + + QueryBuilder + thenByTokenAddress() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'tokenAddress', Sort.asc); + }); + } + + QueryBuilder + thenByTokenAddressDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'tokenAddress', Sort.desc); + }); + } + + QueryBuilder + thenByTokenFractionDigits() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'tokenFractionDigits', Sort.asc); + }); + } + + QueryBuilder + thenByTokenFractionDigitsDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'tokenFractionDigits', Sort.desc); + }); + } + + QueryBuilder + thenByWalletId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'walletId', Sort.asc); + }); + } + + QueryBuilder + thenByWalletIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'walletId', Sort.desc); + }); + } +} + +extension WalletSolanaTokenInfoQueryWhereDistinct + on QueryBuilder { + QueryBuilder + distinctByCachedBalanceJsonString({bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy( + r'cachedBalanceJsonString', + caseSensitive: caseSensitive, + ); + }); + } + + QueryBuilder + distinctByTokenAddress({bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'tokenAddress', caseSensitive: caseSensitive); + }); + } + + QueryBuilder + distinctByTokenFractionDigits() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'tokenFractionDigits'); + }); + } + + QueryBuilder + distinctByWalletId({bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'walletId', caseSensitive: caseSensitive); + }); + } +} + +extension WalletSolanaTokenInfoQueryProperty + on + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QQueryProperty + > { + QueryBuilder idProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'id'); + }); + } + + QueryBuilder + cachedBalanceJsonStringProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'cachedBalanceJsonString'); + }); + } + + QueryBuilder + tokenAddressProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'tokenAddress'); + }); + } + + QueryBuilder + tokenFractionDigitsProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'tokenFractionDigits'); + }); + } + + QueryBuilder + walletIdProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'walletId'); + }); + } +} diff --git a/test/cached_electrumx_test.mocks.dart b/test/cached_electrumx_test.mocks.dart index 1da0fa70b..504934e68 100644 --- a/test/cached_electrumx_test.mocks.dart +++ b/test/cached_electrumx_test.mocks.dart @@ -864,6 +864,19 @@ class MockPrefs extends _i1.Mock implements _i10.Prefs { ) as ({bool enabled, int minutes})); + @override + bool get privacyScreen => + (super.noSuchMethod(Invocation.getter(#privacyScreen), returnValue: false) + as bool); + + @override + bool get disableScreenShots => + (super.noSuchMethod( + Invocation.getter(#disableScreenShots), + returnValue: false, + ) + as bool); + @override set lastUnlockedTimeout(int? lastUnlockedTimeout) => super.noSuchMethod( Invocation.setter(#lastUnlockedTimeout, lastUnlockedTimeout), @@ -1093,6 +1106,18 @@ class MockPrefs extends _i1.Mock implements _i10.Prefs { returnValueForMissingStub: null, ); + @override + set privacyScreen(bool? privacyScreen) => super.noSuchMethod( + Invocation.setter(#privacyScreen, privacyScreen), + returnValueForMissingStub: null, + ); + + @override + set disableScreenShots(bool? disableScreenShots) => super.noSuchMethod( + Invocation.setter(#disableScreenShots, disableScreenShots), + returnValueForMissingStub: null, + ); + @override bool get hasListeners => (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) diff --git a/test/pages/send_view/send_view_test.mocks.dart b/test/pages/send_view/send_view_test.mocks.dart index 91fa2a84e..36200d389 100644 --- a/test/pages/send_view/send_view_test.mocks.dart +++ b/test/pages/send_view/send_view_test.mocks.dart @@ -778,6 +778,19 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { ) as ({bool enabled, int minutes})); + @override + bool get privacyScreen => + (super.noSuchMethod(Invocation.getter(#privacyScreen), returnValue: false) + as bool); + + @override + bool get disableScreenShots => + (super.noSuchMethod( + Invocation.getter(#disableScreenShots), + returnValue: false, + ) + as bool); + @override set lastUnlockedTimeout(int? lastUnlockedTimeout) => super.noSuchMethod( Invocation.setter(#lastUnlockedTimeout, lastUnlockedTimeout), @@ -1007,6 +1020,18 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { returnValueForMissingStub: null, ); + @override + set privacyScreen(bool? privacyScreen) => super.noSuchMethod( + Invocation.setter(#privacyScreen, privacyScreen), + returnValueForMissingStub: null, + ); + + @override + set disableScreenShots(bool? disableScreenShots) => super.noSuchMethod( + Invocation.setter(#disableScreenShots, disableScreenShots), + returnValueForMissingStub: null, + ); + @override bool get hasListeners => (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) diff --git a/test/screen_tests/exchange/exchange_view_test.mocks.dart b/test/screen_tests/exchange/exchange_view_test.mocks.dart index f27f33274..56bee69bf 100644 --- a/test/screen_tests/exchange/exchange_view_test.mocks.dart +++ b/test/screen_tests/exchange/exchange_view_test.mocks.dart @@ -326,6 +326,19 @@ class MockPrefs extends _i1.Mock implements _i5.Prefs { ) as ({bool enabled, int minutes})); + @override + bool get privacyScreen => + (super.noSuchMethod(Invocation.getter(#privacyScreen), returnValue: false) + as bool); + + @override + bool get disableScreenShots => + (super.noSuchMethod( + Invocation.getter(#disableScreenShots), + returnValue: false, + ) + as bool); + @override set lastUnlockedTimeout(int? lastUnlockedTimeout) => super.noSuchMethod( Invocation.setter(#lastUnlockedTimeout, lastUnlockedTimeout), @@ -555,6 +568,18 @@ class MockPrefs extends _i1.Mock implements _i5.Prefs { returnValueForMissingStub: null, ); + @override + set privacyScreen(bool? privacyScreen) => super.noSuchMethod( + Invocation.setter(#privacyScreen, privacyScreen), + returnValueForMissingStub: null, + ); + + @override + set disableScreenShots(bool? disableScreenShots) => super.noSuchMethod( + Invocation.setter(#disableScreenShots, disableScreenShots), + returnValueForMissingStub: null, + ); + @override bool get hasListeners => (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) diff --git a/test/widget_tests/managed_favorite_test.mocks.dart b/test/widget_tests/managed_favorite_test.mocks.dart index 4cbe4bf17..28d64ceb6 100644 --- a/test/widget_tests/managed_favorite_test.mocks.dart +++ b/test/widget_tests/managed_favorite_test.mocks.dart @@ -551,6 +551,19 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { ) as ({bool enabled, int minutes})); + @override + bool get privacyScreen => + (super.noSuchMethod(Invocation.getter(#privacyScreen), returnValue: false) + as bool); + + @override + bool get disableScreenShots => + (super.noSuchMethod( + Invocation.getter(#disableScreenShots), + returnValue: false, + ) + as bool); + @override set lastUnlockedTimeout(int? lastUnlockedTimeout) => super.noSuchMethod( Invocation.setter(#lastUnlockedTimeout, lastUnlockedTimeout), @@ -780,6 +793,18 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { returnValueForMissingStub: null, ); + @override + set privacyScreen(bool? privacyScreen) => super.noSuchMethod( + Invocation.setter(#privacyScreen, privacyScreen), + returnValueForMissingStub: null, + ); + + @override + set disableScreenShots(bool? disableScreenShots) => super.noSuchMethod( + Invocation.setter(#disableScreenShots, disableScreenShots), + returnValueForMissingStub: null, + ); + @override bool get hasListeners => (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) diff --git a/test/widget_tests/node_options_sheet_test.mocks.dart b/test/widget_tests/node_options_sheet_test.mocks.dart index 23b04ffd9..e30eade5d 100644 --- a/test/widget_tests/node_options_sheet_test.mocks.dart +++ b/test/widget_tests/node_options_sheet_test.mocks.dart @@ -29,7 +29,6 @@ import 'package:stackwallet/wallets/isar/models/wallet_info.dart' as _i11; import 'package:stackwallet/wallets/wallet/wallet.dart' as _i5; import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/cash_fusion_interface.dart' as _i6; -import 'package:tor_ffi_plugin/tor_ffi_plugin.dart' as _i22; // ignore_for_file: type=lint // ignore_for_file: avoid_redundant_argument_values @@ -445,6 +444,19 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { ) as ({bool enabled, int minutes})); + @override + bool get privacyScreen => + (super.noSuchMethod(Invocation.getter(#privacyScreen), returnValue: false) + as bool); + + @override + bool get disableScreenShots => + (super.noSuchMethod( + Invocation.getter(#disableScreenShots), + returnValue: false, + ) + as bool); + @override set lastUnlockedTimeout(int? lastUnlockedTimeout) => super.noSuchMethod( Invocation.setter(#lastUnlockedTimeout, lastUnlockedTimeout), @@ -674,6 +686,18 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { returnValueForMissingStub: null, ); + @override + set privacyScreen(bool? privacyScreen) => super.noSuchMethod( + Invocation.setter(#privacyScreen, privacyScreen), + returnValueForMissingStub: null, + ); + + @override + set disableScreenShots(bool? disableScreenShots) => super.noSuchMethod( + Invocation.setter(#disableScreenShots, disableScreenShots), + returnValueForMissingStub: null, + ); + @override bool get hasListeners => (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) @@ -1008,14 +1032,10 @@ class MockTorService extends _i1.Mock implements _i20.TorService { as ({_i8.InternetAddress host, int port})); @override - void init({required String? torDataDirPath, _i22.Tor? mockableOverride}) => - super.noSuchMethod( - Invocation.method(#init, [], { - #torDataDirPath: torDataDirPath, - #mockableOverride: mockableOverride, - }), - returnValueForMissingStub: null, - ); + void init({required String? torDataDirPath}) => super.noSuchMethod( + Invocation.method(#init, [], {#torDataDirPath: torDataDirPath}), + returnValueForMissingStub: null, + ); @override _i10.Future start() => diff --git a/test/widget_tests/transaction_card_test.mocks.dart b/test/widget_tests/transaction_card_test.mocks.dart index 4ac1c8177..aa0c16d81 100644 --- a/test/widget_tests/transaction_card_test.mocks.dart +++ b/test/widget_tests/transaction_card_test.mocks.dart @@ -520,6 +520,19 @@ class MockPrefs extends _i1.Mock implements _i13.Prefs { ) as ({bool enabled, int minutes})); + @override + bool get privacyScreen => + (super.noSuchMethod(Invocation.getter(#privacyScreen), returnValue: false) + as bool); + + @override + bool get disableScreenShots => + (super.noSuchMethod( + Invocation.getter(#disableScreenShots), + returnValue: false, + ) + as bool); + @override set lastUnlockedTimeout(int? lastUnlockedTimeout) => super.noSuchMethod( Invocation.setter(#lastUnlockedTimeout, lastUnlockedTimeout), @@ -749,6 +762,18 @@ class MockPrefs extends _i1.Mock implements _i13.Prefs { returnValueForMissingStub: null, ); + @override + set privacyScreen(bool? privacyScreen) => super.noSuchMethod( + Invocation.setter(#privacyScreen, privacyScreen), + returnValueForMissingStub: null, + ); + + @override + set disableScreenShots(bool? disableScreenShots) => super.noSuchMethod( + Invocation.setter(#disableScreenShots, disableScreenShots), + returnValueForMissingStub: null, + ); + @override bool get hasListeners => (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) @@ -910,6 +935,14 @@ class MockPriceService extends _i1.Mock implements _i21.PriceService { ) as _i10.Future>); + @override + _i10.Future> get solTokenContractAddressesToCheck => + (super.noSuchMethod( + Invocation.getter(#solTokenContractAddressesToCheck), + returnValue: _i10.Future>.value({}), + ) + as _i10.Future>); + @override Duration get updateInterval => (super.noSuchMethod( @@ -1647,6 +1680,48 @@ class MockMainDB extends _i1.Mock implements _i3.MainDB { returnValueForMissingStub: _i10.Future.value(), ) as _i10.Future); + + @override + _i8.QueryBuilder<_i28.SplToken, _i28.SplToken, _i8.QWhere> getSplTokens() => + (super.noSuchMethod( + Invocation.method(#getSplTokens, []), + returnValue: + _FakeQueryBuilder_7<_i28.SplToken, _i28.SplToken, _i8.QWhere>( + this, + Invocation.method(#getSplTokens, []), + ), + ) + as _i8.QueryBuilder<_i28.SplToken, _i28.SplToken, _i8.QWhere>); + + @override + _i10.Future<_i28.SplToken?> getSplToken(String? tokenMint) => + (super.noSuchMethod( + Invocation.method(#getSplToken, [tokenMint]), + returnValue: _i10.Future<_i28.SplToken?>.value(), + ) + as _i10.Future<_i28.SplToken?>); + + @override + _i28.SplToken? getSplTokenSync(String? tokenMint) => + (super.noSuchMethod(Invocation.method(#getSplTokenSync, [tokenMint])) + as _i28.SplToken?); + + @override + _i10.Future putSplToken(_i28.SplToken? token) => + (super.noSuchMethod( + Invocation.method(#putSplToken, [token]), + returnValue: _i10.Future.value(0), + ) + as _i10.Future); + + @override + _i10.Future putSplTokens(List<_i28.SplToken>? tokens) => + (super.noSuchMethod( + Invocation.method(#putSplTokens, [tokens]), + returnValue: _i10.Future.value(), + returnValueForMissingStub: _i10.Future.value(), + ) + as _i10.Future); } /// A class which mocks [IThemeAssets]. From 9b8cb120faf60cba54d3ca0431031c7407229b78 Mon Sep 17 00:00:00 2001 From: julian Date: Wed, 19 Nov 2025 14:19:54 -0600 Subject: [PATCH 55/80] remove non existent asset getters --- lib/utilities/assets.dart | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/lib/utilities/assets.dart b/lib/utilities/assets.dart index e75bf8eb4..d912567d3 100644 --- a/lib/utilities/assets.dart +++ b/lib/utilities/assets.dart @@ -233,29 +233,6 @@ class _SVG { String get trocadorRatingC => "assets/svg/trocador_rating_c.svg"; String get trocadorRatingD => "assets/svg/trocador_rating_d.svg"; - // TODO provide proper assets - String get bitcoinTestnet => "assets/svg/coin_icons/Bitcoin.svg"; - String get bitcoincashTestnet => "assets/svg/coin_icons/Bitcoincash.svg"; - String get firoTestnet => "assets/svg/coin_icons/Firo.svg"; - String get dogecoinTestnet => "assets/svg/coin_icons/Dogecoin.svg"; - String get particlTestnet => "assets/svg/coin_icons/Particl.svg"; - - // small icons - String get bitcoin => "assets/svg/coin_icons/Bitcoin.svg"; - String get litecoin => "assets/svg/coin_icons/Litecoin.svg"; - String get bitcoincash => "assets/svg/coin_icons/Bitcoincash.svg"; - String get dogecoin => "assets/svg/coin_icons/Dogecoin.svg"; - String get epicCash => "assets/svg/coin_icons/EpicCash.svg"; - String get mimblewimblecoin => "assets/svg/coin_icons/Mimblewimblecoin.svg"; - String get ethereum => "assets/svg/coin_icons/Ethereum.svg"; - String get firo => "assets/svg/coin_icons/Firo.svg"; - String get monero => "assets/svg/coin_icons/Monero.svg"; - String get wownero => "assets/svg/coin_icons/Wownero.svg"; - String get namecoin => "assets/svg/coin_icons/Namecoin.svg"; - String get particl => "assets/svg/coin_icons/Particl.svg"; - - String get bnbIcon => "assets/svg/coin_icons/bnb_icon.svg"; - String get spark => "assets/svg/spark.svg"; } From 94cac105fef7376949c73182b498b2c8cae7da0a Mon Sep 17 00:00:00 2001 From: julian Date: Wed, 19 Nov 2025 14:37:31 -0600 Subject: [PATCH 56/80] clean up token icons --- lib/widgets/icon_widgets/eth_token_icon.dart | 28 ++++++++--------- lib/widgets/icon_widgets/sol_token_icon.dart | 32 ++++++-------------- 2 files changed, 24 insertions(+), 36 deletions(-) diff --git a/lib/widgets/icon_widgets/eth_token_icon.dart b/lib/widgets/icon_widgets/eth_token_icon.dart index 0b0104fbf..856474283 100644 --- a/lib/widgets/icon_widgets/eth_token_icon.dart +++ b/lib/widgets/icon_widgets/eth_token_icon.dart @@ -8,6 +8,8 @@ * */ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; @@ -18,6 +20,7 @@ import '../../services/exchange/change_now/change_now_exchange.dart'; import '../../services/exchange/exchange_data_loading_service.dart'; import '../../themes/coin_icon_provider.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; +import '../loading_indicator.dart'; class EthTokenIcon extends ConsumerStatefulWidget { const EthTokenIcon({ @@ -41,18 +44,14 @@ class _EthTokenIconState extends ConsumerState { super.initState(); ExchangeDataLoadingService.instance.isar.then((isar) async { - final currency = - await isar.currencies - .where() - .exchangeNameEqualTo(ChangeNowExchange.exchangeName) - .filter() - .tokenContractEqualTo( - widget.contractAddress, - caseSensitive: false, - ) - .and() - .imageIsNotEmpty() - .findFirst(); + final currency = await isar.currencies + .where() + .exchangeNameEqualTo(ChangeNowExchange.exchangeName) + .filter() + .tokenContractEqualTo(widget.contractAddress, caseSensitive: false) + .and() + .imageIsNotEmpty() + .findFirst(); if (mounted) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -69,8 +68,8 @@ class _EthTokenIconState extends ConsumerState { @override Widget build(BuildContext context) { if (imageUrl == null || imageUrl!.isEmpty) { - return SvgPicture.asset( - ref.watch(coinIconProvider(Ethereum(CryptoCurrencyNetwork.main))), + return SvgPicture.file( + File(ref.watch(coinIconProvider(Ethereum(.main)))), width: widget.size, height: widget.size, ); @@ -79,6 +78,7 @@ class _EthTokenIconState extends ConsumerState { imageUrl!, width: widget.size, height: widget.size, + placeholderBuilder: (_) => const LoadingIndicator(), ); } } diff --git a/lib/widgets/icon_widgets/sol_token_icon.dart b/lib/widgets/icon_widgets/sol_token_icon.dart index b17708b08..dc01d1789 100644 --- a/lib/widgets/icon_widgets/sol_token_icon.dart +++ b/lib/widgets/icon_widgets/sol_token_icon.dart @@ -18,7 +18,9 @@ import '../../models/isar/exchange_cache/currency.dart'; import '../../services/exchange/change_now/change_now_exchange.dart'; import '../../services/exchange/exchange_data_loading_service.dart'; import '../../themes/coin_icon_provider.dart'; +import '../../utilities/logger.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; +import '../loading_indicator.dart'; /// Token icon widget for Solana SPL tokens. /// @@ -30,7 +32,6 @@ class SolTokenIcon extends ConsumerStatefulWidget { /// The SPL token mint address. final String mintAddress; - /// Size of the icon in pixels. final double size; @override @@ -67,13 +68,8 @@ class _SolTokenIconState extends ConsumerState { } }); } - } catch (e) { - // Silently fail - we'll use fallback icon. - if (mounted) { - setState(() { - imageUrl = null; - }); - } + } catch (e, s) { + Logging.instance.e("", error: e, stackTrace: s); } } @@ -81,27 +77,19 @@ class _SolTokenIconState extends ConsumerState { Widget build(BuildContext context) { if (imageUrl == null || imageUrl!.isEmpty) { // Fallback to Solana coin icon from theme. - return _buildSolanaIcon(); + return SvgPicture.file( + File(ref.watch(coinIconProvider(Solana(.main)))), + width: widget.size, + height: widget.size, + ); } else { // Display token icon from network. return SvgPicture.network( imageUrl!, width: widget.size, height: widget.size, - placeholderBuilder: (context) { - return _buildSolanaIcon(); - }, + placeholderBuilder: (_) => const LoadingIndicator(), ); } } - - /// Build a Solana icon from the theme assets using file path, not asset bundle. - Widget _buildSolanaIcon() { - final assetPath = ref.watch(coinIconProvider(Solana(CryptoCurrencyNetwork.main))); - return SvgPicture.file( - File(assetPath), - width: widget.size, - height: widget.size, - ); - } } From 5d6a0ca41885ec67351b2f3814576c8e56efc49a Mon Sep 17 00:00:00 2001 From: julian Date: Wed, 19 Nov 2025 15:15:18 -0600 Subject: [PATCH 57/80] various --- lib/pages/token_view/sol_token_view.dart | 21 ++---- .../wallet_view/desktop_sol_token_view.dart | 15 +--- .../sub_widgets/desktop_wallet_summary.dart | 73 +++++++++---------- .../impl/sub_wallets/solana_token_wallet.dart | 23 +++--- 4 files changed, 56 insertions(+), 76 deletions(-) diff --git a/lib/pages/token_view/sol_token_view.dart b/lib/pages/token_view/sol_token_view.dart index 7efe2dc69..2673404c3 100644 --- a/lib/pages/token_view/sol_token_view.dart +++ b/lib/pages/token_view/sol_token_view.dart @@ -12,6 +12,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; +import '../../models/isar/models/isar_models.dart'; import '../../providers/db/main_db_provider.dart'; import '../../providers/providers.dart'; import '../../services/event_bus/events/global/wallet_sync_status_changed_event.dart'; @@ -63,7 +64,7 @@ class _SolTokenViewState extends ConsumerState { : WalletSyncStatus.synced; // Initialize the Solana token wallet provider with mock data. - // + // // This sets up the pCurrentSolanaTokenWallet provider so that // SolanaTokenSummary can access the token wallet information. WidgetsBinding.instance.addPostFrameCallback((_) { @@ -76,15 +77,15 @@ class _SolTokenViewState extends ConsumerState { } /// Initialize the Solana token wallet for this token view. - /// + /// /// Creates a SolanaTokenWallet with token data from DefaultSplTokens or the database. /// First looks in DefaultSplTokens, then checks the database for custom tokens. /// Sets it as the current token wallet in the provider so that UI widgets can access it. - /// + /// /// If the token is not found anywhere, sets the token wallet to null /// so the UI can display an error message. void _initializeSolanaTokenWallet() { - dynamic tokenInfo; + SplToken? tokenInfo; // First try to find in default tokens. try { @@ -119,19 +120,11 @@ class _SolTokenViewState extends ConsumerState { if (parentWallet == null) { ref.read(solanaTokenServiceStateProvider.state).state = null; - debugPrint( - 'ERROR: Wallet is not a SolanaWallet: ${widget.walletId}', - ); + debugPrint('ERROR: Wallet is not a SolanaWallet: ${widget.walletId}'); return; } - final solanaTokenWallet = SolanaTokenWallet( - parentSolanaWallet: parentWallet, - tokenMint: widget.tokenMint, - tokenName: "${tokenInfo.name}", - tokenSymbol: "${tokenInfo.symbol}", - tokenDecimals: tokenInfo.decimals as int, - ); + final solanaTokenWallet = SolanaTokenWallet(parentWallet, tokenInfo); ref.read(solanaTokenServiceStateProvider.state).state = solanaTokenWallet; diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart index f17c37a9f..7f9631597 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart @@ -12,6 +12,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; +import '../../../models/isar/models/isar_models.dart'; import '../../../pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart'; import '../../../pages/token_view/sub_widgets/token_transaction_list_widget_sol.dart'; import '../../../providers/db/main_db_provider.dart'; @@ -84,7 +85,7 @@ class _DesktopTokenViewState extends ConsumerState { /// so the UI can display an error message. void _initializeSolanaTokenWallet() { // First try to find in default tokens - dynamic tokenInfo; + SplToken? tokenInfo; try { tokenInfo = DefaultSplTokens.list.firstWhere( (token) => token.address == widget.tokenMint, @@ -117,19 +118,11 @@ class _DesktopTokenViewState extends ConsumerState { if (parentWallet == null) { ref.read(solanaTokenServiceStateProvider.state).state = null; - debugPrint( - 'ERROR: Wallet is not a SolanaWallet: ${widget.walletId}', - ); + debugPrint('ERROR: Wallet is not a SolanaWallet: ${widget.walletId}'); return; } - final solanaTokenWallet = SolanaTokenWallet( - parentSolanaWallet: parentWallet, - tokenMint: widget.tokenMint, - tokenName: "${tokenInfo.name}", - tokenSymbol: "${tokenInfo.symbol}", - tokenDecimals: tokenInfo.decimals as int, - ); + final solanaTokenWallet = SolanaTokenWallet(parentWallet, tokenInfo); ref.read(solanaTokenServiceStateProvider.state).state = solanaTokenWallet; diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_summary.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_summary.dart index 9071a9ef5..99c671dd0 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_summary.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_summary.dart @@ -23,7 +23,9 @@ import '../../../../utilities/amount/amount.dart'; import '../../../../utilities/amount/amount_formatter.dart'; import '../../../../utilities/enums/wallet_balance_toggle_state.dart'; import '../../../../utilities/text_styles.dart'; +import '../../../../wallets/crypto_currency/coins/ethereum.dart'; import '../../../../wallets/crypto_currency/coins/firo.dart'; +import '../../../../wallets/crypto_currency/coins/solana.dart'; import '../../../../wallets/crypto_currency/crypto_currency.dart' show CryptoCurrency; import '../../../../wallets/isar/providers/eth/current_token_wallet_provider.dart'; @@ -31,6 +33,7 @@ import '../../../../wallets/isar/providers/eth/token_balance_provider.dart'; import '../../../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; import '../../../../wallets/isar/providers/solana/sol_token_balance_provider.dart'; import '../../../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../../../wallets/wallet/impl/sub_wallets/solana_token_wallet.dart'; import 'desktop_balance_toggle_button.dart'; class DesktopWalletSummary extends ConsumerStatefulWidget { @@ -81,42 +84,43 @@ class _WDesktopWalletSummaryState extends ConsumerState { ); // For Ethereum tokens, get the token contract; for Solana tokens, get the token wallet. - dynamic tokenContract; - dynamic solanaTokenWallet; + final EthContract? tokenContract; + final SolanaTokenWallet? solanaTokenWallet; if (widget.isToken) { - try { - tokenContract = ref.watch( - pCurrentTokenWallet.select((value) => value!.tokenContract), - ); - } catch (_) { - // Ethereum token not found, check for Solana. - tokenContract = null; - } + switch (ref.watch(pWalletCoin(walletId))) { + case Ethereum(): + tokenContract = ref.watch( + pCurrentTokenWallet.select((value) => value!.tokenContract), + ); + solanaTokenWallet = null; + break; + + case Solana(): + tokenContract = null; + // this cannot be null if coin is sol and isToken is true. + // if it is null, then there is a bug somewhere else. + solanaTokenWallet = ref.watch(pCurrentSolanaTokenWallet)!; + break; - // Check for Solana token wallet if Ethereum token not found. - if (tokenContract == null) { - try { - solanaTokenWallet = ref.watch(pCurrentSolanaTokenWallet); - } catch (_) { + default: + tokenContract = null; solanaTokenWallet = null; - } } + } else { + tokenContract = null; + solanaTokenWallet = null; } final price = widget.isToken && tokenContract != null ? ref.watch( priceAnd24hChangeNotifierProvider.select( - (value) => value.getTokenPrice( - (tokenContract as dynamic).address as String, - ), + (value) => value.getTokenPrice(tokenContract!.address), ), ) : widget.isToken && solanaTokenWallet != null ? ref.watch( priceAnd24hChangeNotifierProvider.select( - (value) => value.getTokenPrice( - "${(solanaTokenWallet as dynamic).tokenMint}", - ), + (value) => value.getTokenPrice(solanaTokenWallet!.tokenMint), ), ) : ref.watch( @@ -149,7 +153,7 @@ class _WDesktopWalletSummaryState extends ConsumerState { balance = ref.watch( pTokenBalance(( walletId: walletId, - contractAddress: (tokenContract as dynamic).address as String, + contractAddress: tokenContract.address, )), ); } else if (widget.isToken && solanaTokenWallet != null) { @@ -157,7 +161,7 @@ class _WDesktopWalletSummaryState extends ConsumerState { balance = ref.watch( pSolanaTokenBalance(( walletId: walletId, - tokenMint: (solanaTokenWallet as dynamic).tokenMint, + tokenMint: solanaTokenWallet.tokenMint, )), ); } else { @@ -179,18 +183,13 @@ class _WDesktopWalletSummaryState extends ConsumerState { FittedBox( fit: BoxFit.scaleDown, child: SelectableText( - widget.isToken && solanaTokenWallet != null - ? "${balanceToShow.decimal.toStringAsFixed( - (solanaTokenWallet as dynamic).tokenDecimals as int, - )} ${(solanaTokenWallet as dynamic).tokenSymbol}" - : ref - .watch(pAmountFormatter(coin)) - .format( - balanceToShow, - ethContract: tokenContract != null - ? tokenContract as EthContract? - : null, - ), + ref + .watch(pAmountFormatter(coin)) + .format( + balanceToShow, + ethContract: tokenContract, + splToken: solanaTokenWallet?.splToken, + ), style: STextStyles.desktopH3(context), ), ), @@ -222,7 +221,7 @@ class _WDesktopWalletSummaryState extends ConsumerState { walletId: walletId, initialSyncStatus: widget.initialSyncStatus, tokenContractAddress: widget.isToken && tokenContract != null - ? (tokenContract as EthContract).address + ? tokenContract.address : null, ), diff --git a/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart b/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart index c41ca6cb4..69883f58a 100644 --- a/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart +++ b/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart @@ -15,16 +15,14 @@ import 'package:solana/solana.dart' hide Wallet; import '../../../../db/isar/main_db.dart'; import '../../../../models/balance.dart'; -import '../../../../models/isar/models/blockchain_data/transaction.dart'; import '../../../../models/isar/models/blockchain_data/v2/input_v2.dart'; import '../../../../models/isar/models/blockchain_data/v2/output_v2.dart'; import '../../../../models/isar/models/blockchain_data/v2/transaction_v2.dart'; +import '../../../../models/isar/models/isar_models.dart'; import '../../../../models/paymint/fee_object_model.dart'; import '../../../../services/solana/solana_token_api.dart'; import '../../../../utilities/amount/amount.dart'; import '../../../../utilities/logger.dart'; -import '../../../crypto_currency/crypto_currency.dart'; -import '../../../isar/models/wallet_solana_token_info.dart'; import '../../../models/tx_data.dart'; import '../../wallet.dart'; import '../solana_wallet.dart'; @@ -37,21 +35,18 @@ class SolanaTokenWallet extends Wallet { /// Create a new Solana Token Wallet. /// /// Requires a parent SolanaWallet to provide RPC client and key management. - SolanaTokenWallet({ - required this.parentSolanaWallet, - required this.tokenMint, - required this.tokenName, - required this.tokenSymbol, - required this.tokenDecimals, - }) : super(Solana(CryptoCurrencyNetwork.main)); // TODO: make testnet-capable. + SolanaTokenWallet(this.parentSolanaWallet, this.splToken) + : super(parentSolanaWallet.cryptoCurrency); /// Parent Solana wallet (provides RPC client and keypair access). final SolanaWallet parentSolanaWallet; - final String tokenMint; - final String tokenName; - final String tokenSymbol; - final int tokenDecimals; + final SplToken splToken; + + String get tokenMint => splToken.address; + String get tokenName => splToken.name; + String get tokenSymbol => splToken.symbol; + int get tokenDecimals => splToken.decimals; /// Override walletId to delegate to parent wallet @override From 234af02f7578149f87f604e5e45e0943d86c1a40 Mon Sep 17 00:00:00 2001 From: julian Date: Wed, 19 Nov 2025 15:21:41 -0600 Subject: [PATCH 58/80] txv2 --- lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart b/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart index 69883f58a..3a35a4911 100644 --- a/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart +++ b/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart @@ -32,6 +32,9 @@ import '../solana_wallet.dart'; /// Implements send functionality for Solana SPL tokens (like USDC, USDT, etc.) /// by delegating RPC calls and key management to the parent SolanaWallet. class SolanaTokenWallet extends Wallet { + @override + int get isarTransactionVersion => 2; + /// Create a new Solana Token Wallet. /// /// Requires a parent SolanaWallet to provide RPC client and key management. From 77c6e94a0f0de067a422e6859396285c84e29681 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 19 Nov 2025 23:37:51 -0600 Subject: [PATCH 59/80] ui(spl): mobile send and receive flows some TODOs remain, see especially the send view --- .../receive_view/sol_token_receive_view.dart | 262 ++++ lib/pages/send_view/sol_token_send_view.dart | 1198 +++++++++++++++++ .../sub_widgets/token_summary_sol.dart | 20 +- lib/route_generator.dart | 28 + 4 files changed, 1498 insertions(+), 10 deletions(-) create mode 100644 lib/pages/receive_view/sol_token_receive_view.dart create mode 100644 lib/pages/send_view/sol_token_send_view.dart diff --git a/lib/pages/receive_view/sol_token_receive_view.dart b/lib/pages/receive_view/sol_token_receive_view.dart new file mode 100644 index 000000000..084335b00 --- /dev/null +++ b/lib/pages/receive_view/sol_token_receive_view.dart @@ -0,0 +1,262 @@ +/* + * 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 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import '../../models/isar/models/isar_models.dart'; +import '../../notifications/show_flush_bar.dart'; +import '../../providers/providers.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/assets.dart'; +import '../../utilities/clipboard_interface.dart'; +import '../../utilities/constants.dart'; +import '../../utilities/text_styles.dart'; +import '../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; +import '../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../widgets/background.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/icon_widgets/sol_token_icon.dart'; +import '../../widgets/qr.dart'; +import '../../widgets/rounded_white_container.dart'; + +class SolTokenReceiveView extends ConsumerStatefulWidget { + const SolTokenReceiveView({ + super.key, + required this.walletId, + required this.tokenMint, + this.clipboard = const ClipboardWrapper(), + }); + + static const String routeName = "/solTokenReceiveView"; + + final String walletId; + final String tokenMint; + final ClipboardInterface clipboard; + + @override + ConsumerState createState() => + _SolTokenReceiveViewState(); +} + +class _SolTokenReceiveViewState extends ConsumerState { + late final String walletId; + late final String tokenMint; + late final ClipboardInterface clipboard; + + @override + void initState() { + walletId = widget.walletId; + tokenMint = widget.tokenMint; + clipboard = widget.clipboard; + super.initState(); + } + + @override + Widget build(BuildContext context) { + debugPrint("BUILD: $runtimeType"); + + final tokenWallet = ref.watch(pCurrentSolanaTokenWallet); + final walletName = ref.watch(pWalletName(walletId)); + final receivingAddress = ref.watch(pWalletReceivingAddress(walletId)); + + return Background( + child: Scaffold( + backgroundColor: Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + tokenWallet != null + ? "Receive ${tokenWallet.tokenSymbol}" + : "Receive Token", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 12), + Text( + "Your Solana address", + style: STextStyles.itemSubtitle(context), + ), + const SizedBox(height: 12), + RoundedWhiteContainer( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: SizedBox( + width: 200, + height: 200, + child: QR( + data: receivingAddress, + size: 200, + ), + ), + ), + const SizedBox(height: 24), + Container( + decoration: BoxDecoration( + color: Theme.of( + context, + ).extension()!.popupBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Row( + children: [ + if (tokenWallet != null) + SolTokenIcon( + mintAddress: tokenMint, + ) + else + SizedBox.square(dimension: 32), + const SizedBox(width: 6), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + walletName, + style: STextStyles.titleBold12( + context, + ).copyWith(fontSize: 14), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + Text( + "Solana wallet", + style: STextStyles.label( + context, + ).copyWith(fontSize: 10), + ), + ], + ), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () async { + await clipboard.setData( + ClipboardData(text: receivingAddress), + ); + if (mounted) { + showFloatingFlushBar( + type: FlushBarType.info, + message: "Address copied", + context: context, + ); + } + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: Theme.of( + context, + ).extension()!.highlight, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SvgPicture.asset( + Assets.svg.copy, + width: 16, + height: 16, + colorFilter: ColorFilter.mode( + Theme.of( + context, + ).extension()!.textDark, + BlendMode.srcIn, + ), + ), + const SizedBox(width: 8), + Text( + "Copy", + style: + STextStyles.smallMed12(context) + .copyWith( + color: Theme.of( + context, + ).extension()! + .textDark, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 24), + Text( + "Address", + style: STextStyles.itemSubtitle(context), + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: Theme.of( + context, + ).extension()!.popupBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: SelectableText( + receivingAddress, + style: STextStyles.label(context), + ), + ), + ), + const SizedBox(height: 24), + ], + ), + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/pages/send_view/sol_token_send_view.dart b/lib/pages/send_view/sol_token_send_view.dart new file mode 100644 index 000000000..396f7f7e2 --- /dev/null +++ b/lib/pages/send_view/sol_token_send_view.dart @@ -0,0 +1,1198 @@ +/* + * 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:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../models/isar/models/isar_models.dart'; +import '../../models/send_view_auto_fill_data.dart'; +import '../../providers/providers.dart'; +import '../../providers/ui/fee_rate_type_state_provider.dart'; +import '../../providers/ui/preview_tx_button_state_provider.dart'; +import '../../route_generator.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/address_utils.dart'; +import '../../utilities/amount/amount.dart'; +import '../../utilities/amount/amount_formatter.dart'; +import '../../utilities/amount/amount_input_formatter.dart'; +import '../../utilities/barcode_scanner_interface.dart'; +import '../../utilities/clipboard_interface.dart'; +import '../../utilities/constants.dart'; +import '../../utilities/logger.dart'; +import '../../utilities/prefs.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../wallets/crypto_currency/crypto_currency.dart'; +import '../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; +import '../../wallets/isar/providers/solana/sol_token_balance_provider.dart'; +import '../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../wallets/models/tx_data.dart'; +import '../../widgets/animated_text.dart'; +import '../../widgets/background.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/icon_widgets/clipboard_icon.dart'; +import '../../widgets/icon_widgets/qrcode_icon.dart'; +import '../../widgets/icon_widgets/sol_token_icon.dart'; +import '../../widgets/icon_widgets/x_icon.dart'; +import '../../widgets/stack_dialog.dart'; +import '../../widgets/stack_text_field.dart'; +import '../../widgets/textfield_icon_button.dart'; +import '../token_view/sol_token_view.dart'; +import 'confirm_transaction_view.dart'; +import 'sub_widgets/building_transaction_dialog.dart'; +import 'sub_widgets/transaction_fee_selection_sheet.dart'; + +class SolTokenSendView extends ConsumerStatefulWidget { + const SolTokenSendView({ + super.key, + required this.walletId, + required this.tokenMint, + this.autoFillData, + this.clipboard = const ClipboardWrapper(), + }); + + static const String routeName = "/solTokenSendView"; + + final String walletId; + final String tokenMint; + final SendViewAutoFillData? autoFillData; + final ClipboardInterface clipboard; + + @override + ConsumerState createState() => _SolTokenSendViewState(); +} + +class _SolTokenSendViewState extends ConsumerState { + late final String walletId; + late final String tokenMint; + late final ClipboardInterface clipboard; + + late TextEditingController sendToController; + late TextEditingController cryptoAmountController; + late TextEditingController baseAmountController; + late TextEditingController noteController; + late TextEditingController feeController; + + late final SendViewAutoFillData? _data; + + final _addressFocusNode = FocusNode(); + final _noteFocusNode = FocusNode(); + final _cryptoFocus = FocusNode(); + final _baseFocus = FocusNode(); + + Amount? _amountToSend; + Amount? _cachedAmountToSend; + String? _address; + + bool _addressToggleFlag = false; + + bool _cryptoAmountChangeLock = false; + late VoidCallback onCryptoAmountChanged; + + final updateFeesTimerDuration = const Duration(milliseconds: 500); + + Timer? _cryptoAmountChangedFeeUpdateTimer; + Timer? _baseAmountChangedFeeUpdateTimer; + late Future _calculateFeesFuture; + String cachedFees = ""; + + void _onTokenSendViewPasteAddressFieldButtonPressed() async { + final ClipboardData? data = await clipboard.getData(Clipboard.kTextPlain); + if (data?.text != null && data!.text!.isNotEmpty) { + String content = data.text!.trim(); + if (content.contains("\n")) { + content = content.substring(0, content.indexOf("\n")); + } + sendToController.text = content.trim(); + _address = content.trim(); + + _updatePreviewButtonState(_address, _amountToSend); + setState(() { + _addressToggleFlag = sendToController.text.isNotEmpty; + }); + } + } + + void _onTokenSendViewScanQrButtonPressed() async { + try { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 75)); + } + + final qrResult = await ref.read(pBarcodeScanner).scan(context: context); + + Logging.instance.d("qrResult content: ${qrResult.rawContent}"); + if (qrResult.rawContent == null) return; + + final paymentData = AddressUtils.parsePaymentUri( + qrResult.rawContent!, + logging: Logging.instance, + ); + + Logging.instance.d("qrResult parsed: $paymentData"); + + if (paymentData != null) { + // auto fill address + _address = paymentData.address.trim(); + sendToController.text = _address!; + + // autofill notes field + if (paymentData.message != null) { + noteController.text = paymentData.message!; + } else if (paymentData.label != null) { + noteController.text = paymentData.label!; + } + + // autofill amount field + if (paymentData.amount != null) { + final tokenWallet = ref.read(pCurrentSolanaTokenWallet); + if (tokenWallet != null) { + final Amount amount = Decimal.parse( + paymentData.amount!, + ).toAmount(fractionDigits: tokenWallet.tokenDecimals); + cryptoAmountController.text = ref + .read(pAmountFormatter(Solana(CryptoCurrencyNetwork.main))) + .format( + amount, + withUnitName: false, + indicatePrecisionLoss: false, + ); + _amountToSend = amount; + } + } + + _updatePreviewButtonState(_address, _amountToSend); + setState(() { + _addressToggleFlag = sendToController.text.isNotEmpty; + }); + + // now check for non standard encoded basic address + } else { + _address = qrResult.rawContent!.split("\n").first.trim(); + sendToController.text = _address ?? ""; + + _updatePreviewButtonState(_address, _amountToSend); + setState(() { + _addressToggleFlag = sendToController.text.isNotEmpty; + }); + } + } on PlatformException catch (e, s) { + if (mounted) { + try { + await checkCamPermDeniedMobileAndOpenAppSettings( + context, + logging: Logging.instance, + ); + } catch (e, s) { + Logging.instance.e( + "Failed to check cam permissions", + error: e, + stackTrace: s, + ); + } + } else { + Logging.instance.w( + "Failed to get camera permissions while trying to scan qr code in SolTokenSendView: ", + error: e, + stackTrace: s, + ); + } + } + } + + void _onFiatAmountFieldChanged(String baseAmountString) { + final baseAmount = Amount.tryParseFiatString( + baseAmountString, + locale: ref.read(localeServiceChangeNotifierProvider).locale, + ); + if (baseAmount != null) { + final _price = ref + .read(priceAnd24hChangeNotifierProvider) + .getTokenPrice(tokenMint) + ?.value; + + final tokenWallet = ref.read(pCurrentSolanaTokenWallet); + if (tokenWallet == null) return; + + if (_price == null || _price == Decimal.zero) { + _amountToSend = Amount.zero; + } else { + _amountToSend = baseAmount <= Amount.zero + ? Amount.zero + : Amount.fromDecimal( + (baseAmount.decimal / _price).toDecimal( + scaleOnInfinitePrecision: tokenWallet.tokenDecimals, + ), + fractionDigits: tokenWallet.tokenDecimals, + ); + } + if (_cachedAmountToSend != null && _cachedAmountToSend == _amountToSend) { + return; + } + _cachedAmountToSend = _amountToSend; + + _cryptoAmountChangeLock = true; + cryptoAmountController.text = ref + .read(pAmountFormatter(Solana(CryptoCurrencyNetwork.main))) + .format(_amountToSend!, withUnitName: false); + _cryptoAmountChangeLock = false; + } else { + _amountToSend = Amount.zero; + _cryptoAmountChangeLock = true; + cryptoAmountController.text = ""; + _cryptoAmountChangeLock = false; + } + _updatePreviewButtonState(_address, _amountToSend); + } + + void _cryptoAmountChanged() async { + if (!_cryptoAmountChangeLock) { + final tokenWallet = ref.read(pCurrentSolanaTokenWallet); + if (tokenWallet == null) return; + + final cryptoAmount = Decimal.tryParse( + cryptoAmountController.text, + )?.toAmount(fractionDigits: tokenWallet.tokenDecimals); + if (cryptoAmount != null) { + _amountToSend = cryptoAmount; + if (_cachedAmountToSend != null && + _cachedAmountToSend == _amountToSend) { + return; + } + _cachedAmountToSend = _amountToSend; + + final price = ref + .read(priceAnd24hChangeNotifierProvider) + .getTokenPrice(tokenMint) + ?.value; + + if (price != null && price > Decimal.zero) { + baseAmountController.text = (_amountToSend!.decimal * price) + .toAmount(fractionDigits: 2) + .fiatString( + locale: ref.read(localeServiceChangeNotifierProvider).locale, + ); + } + } else { + _amountToSend = null; + baseAmountController.text = ""; + } + + _updatePreviewButtonState(_address, _amountToSend); + + _cryptoAmountChangedFeeUpdateTimer?.cancel(); + _cryptoAmountChangedFeeUpdateTimer = Timer(updateFeesTimerDuration, () { + if (mounted) { + setState(() { + _calculateFeesFuture = calculateFees(); + }); + } + }); + } + } + + void _baseAmountChanged() { + _baseAmountChangedFeeUpdateTimer?.cancel(); + _baseAmountChangedFeeUpdateTimer = Timer(updateFeesTimerDuration, () { + if (mounted && !_cryptoFocus.hasFocus) { + setState(() { + _calculateFeesFuture = calculateFees(); + }); + } + }); + } + + String? _updateInvalidAddressText(String address) { + if (_data != null && _data.contactLabel == address) { + return null; + } + if (address.isNotEmpty) { + // TODO: Implement Solana address validation. + // For now, perform basic length check (44 chars is typical for Solana addresses). + if (address.length < 32 || address.length > 44) { + return "Invalid address"; + } + } + return null; + } + + void _updatePreviewButtonState(String? address, Amount? amount) { + // TODO: Implement Solana address validation. + final isValidAddress = + address != null && + address.isNotEmpty && + address.length >= 32 && + address.length <= 44; + ref.read(previewTokenTxButtonStateProvider.state).state = + (isValidAddress && amount != null && amount > Amount.zero); + } + + Future calculateFees() async { + // TODO: Implement Solana fee calculation. + // For now, return a placeholder fee + cachedFees = "0.000005 SOL"; + return cachedFees; + } + + Future _previewTransaction() async { + // wait for keyboard to disappear + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 100)); + + final tokenWallet = ref.read(pCurrentSolanaTokenWallet); + if (tokenWallet == null) { + if (mounted) { + await showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return StackDialog( + title: "Error", + message: "Token wallet not initialized", + rightButton: TextButton( + style: Theme.of(context) + .extension()! + .getSecondaryEnabledButtonStyle(context), + child: Text( + "Ok", + style: STextStyles.button(context).copyWith( + color: Theme.of( + context, + ).extension()!.accentColorDark, + ), + ), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ); + }, + ); + } + return; + } + + final wallet = ref.read(pWallets).getWallet(walletId); + final Amount amount = _amountToSend!; + + try { + bool wasCancelled = false; + + if (mounted) { + unawaited( + showDialog( + context: context, + useSafeArea: false, + barrierDismissible: false, + builder: (context) { + return BuildingTransactionDialog( + coin: wallet.info.coin, + isSpark: false, + onCancel: () { + wasCancelled = true; + + Navigator.of(context).pop(); + }, + ); + }, + ), + ); + } + + final time = Future.delayed(const Duration(milliseconds: 2500)); + + TxData txData; + Future txDataFuture; + + Logging.instance.i( + "SolTokenSendView: Preparing transaction - amount: ${amount.decimal} " + "(raw: ${amount.raw}), decimals: ${tokenWallet.tokenDecimals}, " + "tokenSymbol: ${tokenWallet.tokenSymbol}", + ); + + txDataFuture = tokenWallet.prepareSend( + txData: TxData( + recipients: [ + TxRecipient( + address: _address!, + amount: amount, + isChange: false, + addressType: AddressType.solana, + ), + ], + feeRateType: ref.read(feeRateTypeMobileStateProvider), + note: noteController.text, + tokenMint: tokenMint, + tokenSymbol: tokenWallet.tokenSymbol, + tokenDecimals: tokenWallet.tokenDecimals, + ), + ); + + final results = await Future.wait([txDataFuture, time]); + + txData = results.first as TxData; + + if (!wasCancelled && mounted) { + // pop building dialog + Navigator.of(context).pop(); + + unawaited( + Navigator.of(context).push( + RouteGenerator.getRoute( + shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, + builder: (_) => ConfirmTransactionView( + txData: txData, + walletId: walletId, + isTokenTx: true, + onSuccess: clearSendForm, + routeOnSuccessName: SolTokenView.routeName, + ), + settings: const RouteSettings( + name: ConfirmTransactionView.routeName, + ), + ), + ), + ); + } + } catch (e, s) { + Logging.instance.e("$e\n$s", error: e, stackTrace: s); + if (mounted) { + // pop building dialog + Navigator.of(context).pop(); + + unawaited( + showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return StackDialog( + title: "Transaction failed", + message: e.toString(), + rightButton: TextButton( + style: Theme.of(context) + .extension()! + .getSecondaryEnabledButtonStyle(context), + child: Text( + "Ok", + style: STextStyles.button(context).copyWith( + color: Theme.of( + context, + ).extension()!.accentColorDark, + ), + ), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ); + }, + ), + ); + } + } + } + + void clearSendForm() { + sendToController.text = ""; + cryptoAmountController.text = ""; + baseAmountController.text = ""; + noteController.text = ""; + feeController.text = ""; + _address = ""; + _addressToggleFlag = false; + if (mounted) { + setState(() {}); + } + } + + @override + void initState() { + ref.refresh(feeSheetSessionCacheProvider); + + _calculateFeesFuture = calculateFees(); + _data = widget.autoFillData; + walletId = widget.walletId; + tokenMint = widget.tokenMint; + clipboard = widget.clipboard; + + sendToController = TextEditingController(); + cryptoAmountController = TextEditingController(); + baseAmountController = TextEditingController(); + noteController = TextEditingController(); + feeController = TextEditingController(); + + onCryptoAmountChanged = _cryptoAmountChanged; + cryptoAmountController.addListener(onCryptoAmountChanged); + baseAmountController.addListener(_baseAmountChanged); + + if (_data != null) { + if (_data.amount != null) { + cryptoAmountController.text = _data.amount!.toString(); + } + sendToController.text = _data.contactLabel; + _address = _data.address.trim(); + _addressToggleFlag = true; + } + + super.initState(); + } + + @override + void dispose() { + _cryptoAmountChangedFeeUpdateTimer?.cancel(); + _baseAmountChangedFeeUpdateTimer?.cancel(); + + cryptoAmountController.removeListener(onCryptoAmountChanged); + baseAmountController.removeListener(_baseAmountChanged); + + sendToController.dispose(); + cryptoAmountController.dispose(); + baseAmountController.dispose(); + noteController.dispose(); + feeController.dispose(); + + _noteFocusNode.dispose(); + _addressFocusNode.dispose(); + _cryptoFocus.dispose(); + _baseFocus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + debugPrint("BUILD: $runtimeType"); + final String locale = ref.watch( + localeServiceChangeNotifierProvider.select((value) => value.locale), + ); + + final tokenWallet = ref.watch(pCurrentSolanaTokenWallet); + + Decimal? price; + if (ref.watch(prefsChangeNotifierProvider.select((s) => s.externalCalls))) { + price = ref.watch( + priceAnd24hChangeNotifierProvider.select( + (value) => value.getTokenPrice(tokenMint)?.value, + ), + ); + } + + if (tokenWallet == null) { + return Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 50)); + } + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + ), + title: const Text("Send Token"), + ), + body: const SafeArea(child: Center(child: Text("Loading token..."))), + ), + ); + } + + return Background( + child: Scaffold( + backgroundColor: Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 50)); + } + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + ), + title: Text( + "Send ${tokenWallet.tokenSymbol}", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (builderContext, constraints) { + return Padding( + padding: const EdgeInsets.only(left: 12, top: 12, right: 12), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 24, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + decoration: BoxDecoration( + color: Theme.of( + context, + ).extension()!.popupBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Row( + children: [ + SolTokenIcon(mintAddress: tokenMint), + const SizedBox(width: 6), + Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + ref.watch(pWalletName(walletId)), + style: STextStyles.titleBold12( + context, + ).copyWith(fontSize: 14), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + Text( + "Available balance", + style: STextStyles.label( + context, + ).copyWith(fontSize: 10), + ), + ], + ), + const Spacer(), + GestureDetector( + onTap: () { + cryptoAmountController.text = ref + .watch( + pAmountFormatter( + Solana( + CryptoCurrencyNetwork.main, + ), + ), + ) + .format( + ref + .read( + pSolanaTokenBalance(( + walletId: widget.walletId, + tokenMint: tokenMint, + )), + ) + .spendable, + withUnitName: false, + indicatePrecisionLoss: true, + ); + }, + child: Container( + color: Colors.transparent, + child: Column( + crossAxisAlignment: + CrossAxisAlignment.end, + children: [ + Text( + ref + .watch( + pAmountFormatter( + Solana( + CryptoCurrencyNetwork + .main, + ), + ), + ) + .format( + ref + .watch( + pSolanaTokenBalance(( + walletId: + widget.walletId, + tokenMint: + tokenMint, + )), + ) + .spendable, + ), + style: STextStyles.titleBold12( + context, + ).copyWith(fontSize: 10), + textAlign: TextAlign.right, + ), + if (price != null) + Text( + "${(ref.watch(pSolanaTokenBalance((walletId: widget.walletId, tokenMint: tokenMint))).spendable.decimal * price).toAmount(fractionDigits: 2).fiatString(locale: locale)} ${ref.watch(prefsChangeNotifierProvider.select((value) => value.currency))}", + style: STextStyles.subtitle( + context, + ).copyWith(fontSize: 8), + textAlign: TextAlign.right, + ), + ], + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + Text( + "Send to", + style: STextStyles.smallMed12(context), + textAlign: TextAlign.left, + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: const Key( + "solTokenSendViewAddressFieldKey", + ), + controller: sendToController, + readOnly: false, + autocorrect: false, + enableSuggestions: false, + toolbarOptions: const ToolbarOptions( + copy: false, + cut: false, + paste: true, + selectAll: false, + ), + onChanged: (newValue) { + _address = newValue.trim(); + _updatePreviewButtonState( + _address, + _amountToSend, + ); + + setState(() { + _addressToggleFlag = newValue.isNotEmpty; + }); + }, + focusNode: _addressFocusNode, + style: STextStyles.field(context), + decoration: + standardInputDecoration( + "Enter Solana address", + _addressFocusNode, + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: sendToController.text.isEmpty + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceAround, + children: [ + _addressToggleFlag + ? TextFieldIconButton( + key: const Key( + "solTokenSendViewClearAddressFieldButtonKey", + ), + onTap: () { + sendToController.text = + ""; + _address = ""; + _updatePreviewButtonState( + _address, + _amountToSend, + ); + setState(() { + _addressToggleFlag = + false; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + key: const Key( + "solTokenSendViewPasteAddressFieldButtonKey", + ), + onTap: + _onTokenSendViewPasteAddressFieldButtonPressed, + child: + sendToController + .text + .isEmpty + ? const ClipboardIcon() + : const XIcon(), + ), + if (sendToController.text.isEmpty) + TextFieldIconButton( + key: const Key( + "solSendViewScanQrButtonKey", + ), + onTap: + _onTokenSendViewScanQrButtonPressed, + child: const QrCodeIcon(), + ), + ], + ), + ), + ), + ), + ), + ), + Builder( + builder: (_) { + final error = _updateInvalidAddressText( + _address ?? "", + ); + + if (error == null || error.isEmpty) { + return Container(); + } else { + return Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.only( + left: 12.0, + top: 4.0, + ), + child: Text( + error, + textAlign: TextAlign.left, + style: STextStyles.label(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textError, + ), + ), + ), + ); + } + }, + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Amount", + style: STextStyles.smallMed12(context), + textAlign: TextAlign.left, + ), + ], + ), + const SizedBox(height: 8), + TextField( + autocorrect: Util.isDesktop ? false : true, + enableSuggestions: Util.isDesktop ? false : true, + style: STextStyles.smallMed14(context).copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + ), + key: const Key( + "solAmountInputFieldCryptoTextFieldKey", + ), + controller: cryptoAmountController, + focusNode: _cryptoFocus, + keyboardType: Util.isDesktop + ? null + : const TextInputType.numberWithOptions( + signed: false, + decimal: true, + ), + textAlign: TextAlign.right, + inputFormatters: [ + AmountInputFormatter( + decimals: tokenWallet.tokenDecimals, + // TODO: Implement token-specific unit lookup + // similar to Ethereum's pAmountUnit(coin).unitForContract(tokenContract) + unit: ref.watch( + pAmountUnit( + Solana(CryptoCurrencyNetwork.main), + ), + ), + locale: locale, + ), + ], + decoration: InputDecoration( + contentPadding: const EdgeInsets.only( + top: 12, + right: 12, + ), + hintText: "0", + hintStyle: STextStyles.fieldLabel( + context, + ).copyWith(fontSize: 14), + prefixIcon: FittedBox( + fit: BoxFit.scaleDown, + child: Padding( + padding: const EdgeInsets.all(12), + child: Text( + tokenWallet.tokenSymbol, + style: STextStyles.smallMed14(context) + .copyWith( + color: Theme.of(context) + .extension()! + .accentColorDark, + ), + ), + ), + ), + ), + ), + if (Prefs.instance.externalCalls) + const SizedBox(height: 8), + if (Prefs.instance.externalCalls) + TextField( + autocorrect: Util.isDesktop ? false : true, + enableSuggestions: Util.isDesktop + ? false + : true, + style: STextStyles.smallMed14(context).copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + ), + key: const Key( + "solAmountInputFieldFiatTextFieldKey", + ), + controller: baseAmountController, + focusNode: _baseFocus, + keyboardType: Util.isDesktop + ? null + : const TextInputType.numberWithOptions( + signed: false, + decimal: true, + ), + textAlign: TextAlign.right, + inputFormatters: [ + AmountInputFormatter( + decimals: 2, + locale: locale, + ), + ], + onChanged: _onFiatAmountFieldChanged, + decoration: InputDecoration( + contentPadding: const EdgeInsets.only( + top: 12, + right: 12, + ), + hintText: "0", + hintStyle: STextStyles.fieldLabel( + context, + ).copyWith(fontSize: 14), + prefixIcon: FittedBox( + fit: BoxFit.scaleDown, + child: Padding( + padding: const EdgeInsets.all(12), + child: Text( + ref.watch( + prefsChangeNotifierProvider.select( + (value) => value.currency, + ), + ), + style: STextStyles.smallMed14(context) + .copyWith( + color: Theme.of(context) + .extension()! + .accentColorDark, + ), + ), + ), + ), + ), + ), + const SizedBox(height: 12), + Text( + "Note (optional)", + style: STextStyles.smallMed12(context), + textAlign: TextAlign.left, + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + autocorrect: Util.isDesktop ? false : true, + enableSuggestions: Util.isDesktop + ? false + : true, + controller: noteController, + focusNode: _noteFocusNode, + style: STextStyles.field(context), + onChanged: (_) => setState(() {}), + decoration: + standardInputDecoration( + "Type something...", + _noteFocusNode, + context, + ).copyWith( + suffixIcon: noteController.text.isNotEmpty + ? Padding( + padding: const EdgeInsets.only( + right: 0, + ), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + noteController.text = + ""; + }); + }, + ), + ], + ), + ), + ) + : null, + ), + ), + ), + const SizedBox(height: 12), + Text( + "Transaction fee", + style: STextStyles.smallMed12(context), + textAlign: TextAlign.left, + ), + const SizedBox(height: 8), + Stack( + children: [ + TextField( + autocorrect: Util.isDesktop ? false : true, + enableSuggestions: Util.isDesktop + ? false + : true, + controller: feeController, + readOnly: true, + textInputAction: TextInputAction.none, + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + ), + child: RawMaterialButton( + splashColor: Theme.of( + context, + ).extension()!.highlight, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + onPressed: () { + // TODO: Implement fee selection for Solana. + }, + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + FutureBuilder( + future: _calculateFeesFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == + ConnectionState.done && + snapshot.hasData) { + return Text( + "~${snapshot.data!}", + style: STextStyles.itemSubtitle( + context, + ), + ); + } else { + return AnimatedText( + stringsToLoopThrough: const [ + "Calculating", + "Calculating.", + "Calculating..", + "Calculating...", + ], + style: STextStyles.itemSubtitle( + context, + ), + ); + } + }, + ), + ], + ), + ), + ), + ], + ), + const Spacer(), + const SizedBox(height: 12), + TextButton( + onPressed: + ref + .watch( + previewTokenTxButtonStateProvider.state, + ) + .state + ? _previewTransaction + : null, + style: + ref + .watch( + previewTokenTxButtonStateProvider.state, + ) + .state + ? Theme.of(context) + .extension()! + .getPrimaryEnabledButtonStyle(context) + : Theme.of(context) + .extension()! + .getPrimaryDisabledButtonStyle(context), + child: Text( + "Preview", + style: STextStyles.button(context), + ), + ), + const SizedBox(height: 16), + ], + ), + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/pages/token_view/sub_widgets/token_summary_sol.dart b/lib/pages/token_view/sub_widgets/token_summary_sol.dart index e8de54b79..9d627c1f5 100644 --- a/lib/pages/token_view/sub_widgets/token_summary_sol.dart +++ b/lib/pages/token_view/sub_widgets/token_summary_sol.dart @@ -30,6 +30,8 @@ import '../../../wallets/isar/providers/wallet_info_provider.dart'; import '../../../widgets/coin_ticker_tag.dart'; import '../../../widgets/conditional_parent.dart'; import '../../../widgets/rounded_container.dart'; +import '../../receive_view/sol_token_receive_view.dart'; +import '../../send_view/sol_token_send_view.dart'; import '../../wallet_view/sub_widgets/wallet_refresh_button.dart'; /// Solana-specific token summary widget. @@ -192,11 +194,10 @@ class SolanaTokenWalletOptions extends ConsumerWidget { children: [ TokenOptionsButton( onPressed: () { - // TODO: Navigate to Solana token receive view. - // Navigator.of(context).pushNamed( - // SolTokenReceiveView.routeName, - // arguments: Tuple2(walletId, tokenMint), - // ); + Navigator.of(context).pushNamed( + SolTokenReceiveView.routeName, + arguments: (walletId, tokenMint), + ); }, subLabel: "Receive", iconAssetPathSVG: Assets.svg.arrowDownLeft, @@ -204,11 +205,10 @@ class SolanaTokenWalletOptions extends ConsumerWidget { const SizedBox(width: 16), TokenOptionsButton( onPressed: () { - // TODO: Navigate to Solana token send view. - // Navigator.of(context).pushNamed( - // SolTokenSendView.routeName, - // arguments: Tuple2(walletId, tokenMint), - // ); + Navigator.of(context).pushNamed( + SolTokenSendView.routeName, + arguments: (walletId, tokenMint), + ); }, subLabel: "Send", iconAssetPathSVG: Assets.svg.arrowUpRight, diff --git a/lib/route_generator.dart b/lib/route_generator.dart index d8b740e65..10c09914e 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -94,10 +94,12 @@ import 'pages/receive_view/addresses/edit_address_label_view.dart'; import 'pages/receive_view/addresses/wallet_addresses_view.dart'; import 'pages/receive_view/generate_receiving_uri_qr_code_view.dart'; import 'pages/receive_view/receive_view.dart'; +import 'pages/receive_view/sol_token_receive_view.dart'; import 'pages/salvium_stake/salvium_create_stake_view.dart'; import 'pages/send_view/confirm_transaction_view.dart'; import 'pages/send_view/frost_ms/frost_send_view.dart'; import 'pages/send_view/send_view.dart'; +import 'pages/send_view/sol_token_send_view.dart'; import 'pages/send_view/token_send_view.dart'; import 'pages/settings_views/global_settings_view/about_view.dart'; import 'pages/settings_views/global_settings_view/advanced_views/advanced_settings_view.dart'; @@ -1814,6 +1816,32 @@ class RouteGenerator { } return _routeError("${settings.name} invalid args: ${args.toString()}"); + case SolTokenSendView.routeName: + if (args is (String, String)) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => SolTokenSendView( + walletId: args.$1, + tokenMint: args.$2, + ), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case SolTokenReceiveView.routeName: + if (args is (String, String)) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => SolTokenReceiveView( + walletId: args.$1, + tokenMint: args.$2, + ), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + case ConfirmTransactionView.routeName: if (args is (TxData, String, VoidCallback)) { return getRoute( From 602e2683e3293b6b5f164cf06fa5657676824d2d Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 20 Nov 2025 11:19:51 -0600 Subject: [PATCH 60/80] ui(spl): mobile token details view --- lib/pages/token_view/sol_token_view.dart | 17 +- .../solana_token_contract_details_view.dart | 204 ++++++++++++++++++ lib/route_generator.dart | 14 ++ 3 files changed, 227 insertions(+), 8 deletions(-) create mode 100644 lib/pages/token_view/solana_token_contract_details_view.dart diff --git a/lib/pages/token_view/sol_token_view.dart b/lib/pages/token_view/sol_token_view.dart index 2673404c3..242a6cdf5 100644 --- a/lib/pages/token_view/sol_token_view.dart +++ b/lib/pages/token_view/sol_token_view.dart @@ -11,6 +11,7 @@ import 'package:event_bus/event_bus.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; +import 'package:tuple/tuple.dart'; import '../../models/isar/models/isar_models.dart'; import '../../providers/db/main_db_provider.dart'; @@ -28,6 +29,7 @@ import '../../widgets/background.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../widgets/custom_buttons/blue_text_button.dart'; import '../../widgets/icon_widgets/sol_token_icon.dart'; +import 'solana_token_contract_details_view.dart'; import 'sub_widgets/token_summary_sol.dart'; import 'sub_widgets/token_transaction_list_widget_sol.dart'; @@ -208,14 +210,13 @@ class _SolTokenViewState extends ConsumerState { ).extension()!.topNavIconPrimary, ), onPressed: () { - // TODO: Implement token details navigation for Solana. - // Navigator.of(context).pushNamed( - // TokenContractDetailsView.routeName, - // arguments: Tuple2( - // widget.tokenMint, - // widget.walletId, - // ), - // ); + Navigator.of(context).pushNamed( + SolanaTokenContractDetailsView.routeName, + arguments: Tuple2( + widget.tokenMint, + widget.walletId, + ), + ); }, ), ), diff --git a/lib/pages/token_view/solana_token_contract_details_view.dart b/lib/pages/token_view/solana_token_contract_details_view.dart new file mode 100644 index 000000000..c0b74050b --- /dev/null +++ b/lib/pages/token_view/solana_token_contract_details_view.dart @@ -0,0 +1,204 @@ +/* + * 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 'package:isar_community/isar.dart'; + +import '../../db/isar/main_db.dart'; +import '../../models/isar/models/isar_models.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/default_spl_tokens.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../widgets/background.dart'; +import '../../widgets/conditional_parent.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/custom_buttons/simple_copy_button.dart'; +import '../../widgets/rounded_white_container.dart'; + +class SolanaTokenContractDetailsView extends ConsumerStatefulWidget { + const SolanaTokenContractDetailsView({ + super.key, + required this.tokenMint, + required this.walletId, + }); + + static const String routeName = "/solanaTokenContractDetailsView"; + + final String tokenMint; + final String walletId; + + @override + ConsumerState createState() => + _SolanaTokenContractDetailsViewState(); +} + +class _SolanaTokenContractDetailsViewState + extends ConsumerState { + final isDesktop = Util.isDesktop; + + late SplToken token; + + @override + void initState() { + // Try to find the token in the database first. + final dbToken = MainDB.instance.isar.splTokens + .where() + .addressEqualTo(widget.tokenMint) + .findFirstSync(); + + if (dbToken != null) { + token = dbToken; + } else { + // If not in database, try to find it in default tokens. + try { + token = DefaultSplTokens.list.firstWhere( + (t) => t.address == widget.tokenMint, + ); + } catch (e) { + // Token not found, create a placeholder. + // + // Might want to just throw here instead. + token = SplToken( + address: widget.tokenMint, + name: 'Unknown Token', + symbol: 'UNKNOWN', + decimals: 0, + ); + } + } + + super.initState(); + } + + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: !isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + backgroundColor: Theme.of( + context, + ).extension()!.backgroundAppBar, + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + titleSpacing: 0, + title: Text( + "Token details", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (builderContext, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _Item( + title: "Mint address", + data: token.address, + button: SimpleCopyButton(data: token.address), + ), + const SizedBox(height: 12), + _Item( + title: "Name", + data: token.name, + button: SimpleCopyButton(data: token.name), + ), + const SizedBox(height: 12), + _Item( + title: "Symbol", + data: token.symbol, + button: SimpleCopyButton(data: token.symbol), + ), + const SizedBox(height: 12), + _Item( + title: "Decimals", + data: token.decimals.toString(), + button: SimpleCopyButton(data: token.decimals.toString()), + ), + if (token.metadataAddress != null) ...[ + const SizedBox(height: 12), + _Item( + title: "Metadata address", + data: token.metadataAddress ?? "", + button: SimpleCopyButton(data: token.metadataAddress ?? ""), + ), + ], + ], + ), + ); + } +} + +class _Item extends StatelessWidget { + const _Item({ + super.key, + required this.title, + required this.data, + required this.button, + }); + + final String title; + final String data; + final Widget button; + + @override + Widget build(BuildContext context) { + return RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(title, style: STextStyles.itemSubtitle(context)), + button, + ], + ), + const SizedBox(height: 5), + data.isNotEmpty + ? SelectableText(data, style: STextStyles.w500_14(context)) + : Text( + "$title will appear here", + style: STextStyles.w500_14(context).copyWith( + color: Theme.of( + context, + ).extension()!.textSubtitle3, + ), + ), + ], + ), + ); + } +} diff --git a/lib/route_generator.dart b/lib/route_generator.dart index 10c09914e..1a91b27fb 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -165,6 +165,7 @@ import 'pages/special/firo_rescan_recovery_error_dialog.dart'; import 'pages/stack_privacy_calls.dart'; import 'pages/token_view/my_tokens_view.dart'; import 'pages/token_view/sol_token_view.dart'; +import 'pages/token_view/solana_token_contract_details_view.dart'; import 'pages/token_view/token_contract_details_view.dart'; import 'pages/token_view/token_view.dart'; import 'pages/wallet_view/transaction_views/all_transactions_view.dart'; @@ -433,6 +434,19 @@ class RouteGenerator { } return _routeError("${settings.name} invalid args: ${args.toString()}"); + case SolanaTokenContractDetailsView.routeName: + if (args is Tuple2) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => SolanaTokenContractDetailsView( + tokenMint: args.item1, + walletId: args.item2, + ), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + case SingleFieldEditView.routeName: if (args is Tuple2) { return getRoute( From 75895a256397193b208ce90c84e3f1f4bbb6d38e Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 20 Nov 2025 11:31:39 -0600 Subject: [PATCH 61/80] ui(spl): desktop token details view --- .../wallet_view/desktop_sol_token_view.dart | 32 +++++++--- .../wallet_view/desktop_token_view.dart | 64 ++++++++++++------- 2 files changed, 65 insertions(+), 31 deletions(-) diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart index 7f9631597..ce1978460 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart @@ -11,9 +11,11 @@ import 'package:event_bus/event_bus.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; +import 'package:tuple/tuple.dart'; import '../../../models/isar/models/isar_models.dart'; import '../../../pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart'; +import '../../../pages/token_view/solana_token_contract_details_view.dart'; import '../../../pages/token_view/sub_widgets/token_transaction_list_widget_sol.dart'; import '../../../providers/db/main_db_provider.dart'; import '../../../providers/providers.dart'; @@ -175,14 +177,28 @@ class _DesktopTokenViewState extends ConsumerState { final tokenWallet = ref.watch(pCurrentSolanaTokenWallet); final tokenName = tokenWallet?.tokenName ?? "Token"; final tokenSymbol = tokenWallet?.tokenSymbol ?? "SOL"; - return Row( - children: [ - SolTokenIcon(mintAddress: widget.tokenMint, size: 32), - const SizedBox(width: 12), - Text(tokenName, style: STextStyles.desktopH3(context)), - const SizedBox(width: 12), - CoinTickerTag(ticker: tokenSymbol), - ], + return GestureDetector( + onTap: () { + Navigator.of(context).pushNamed( + SolanaTokenContractDetailsView.routeName, + arguments: Tuple2( + widget.tokenMint, + widget.walletId, + ), + ); + }, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Row( + children: [ + SolTokenIcon(mintAddress: widget.tokenMint, size: 32), + const SizedBox(width: 12), + Text(tokenName, style: STextStyles.desktopH3(context)), + const SizedBox(width: 12), + CoinTickerTag(ticker: tokenSymbol), + ], + ), + ), ); }, ), diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_token_view.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_token_view.dart index 577d4e322..c1597a4e8 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_token_view.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_token_view.dart @@ -12,9 +12,11 @@ import 'package:event_bus/event_bus.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; +import 'package:tuple/tuple.dart'; import '../../../pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart'; import '../../../pages/token_view/sub_widgets/token_transaction_list_widget.dart'; +import '../../../pages/token_view/token_contract_details_view.dart'; import '../../../pages/wallet_view/transaction_views/tx_v2/all_transactions_v2_view.dart'; import '../../../providers/providers.dart'; import '../../../services/event_bus/events/global/wallet_sync_status_changed_event.dart'; @@ -102,32 +104,48 @@ class _DesktopTokenViewState extends ConsumerState { ), center: Expanded( flex: 4, - child: Row( - children: [ - EthTokenIcon( - contractAddress: ref.watch( - pCurrentTokenWallet.select( - (value) => value!.tokenContract.address, - ), + child: GestureDetector( + onTap: () { + final contractAddress = ref.watch( + pCurrentTokenWallet.select( + (value) => value!.tokenContract.address, ), - size: 32, - ), - const SizedBox(width: 12), - Text( - ref.watch( - pCurrentTokenWallet.select( - (value) => value!.tokenContract.name, + ); + Navigator.of(context).pushNamed( + TokenContractDetailsView.routeName, + arguments: Tuple2(contractAddress, widget.walletId), + ); + }, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Row( + children: [ + EthTokenIcon( + contractAddress: ref.watch( + pCurrentTokenWallet.select( + (value) => value!.tokenContract.address, + ), + ), + size: 32, ), - ), - style: STextStyles.desktopH3(context), - ), - const SizedBox(width: 12), - CoinTickerTag( - ticker: ref.watch( - pWalletCoin(widget.walletId).select((s) => s.ticker), - ), + const SizedBox(width: 12), + Text( + ref.watch( + pCurrentTokenWallet.select( + (value) => value!.tokenContract.name, + ), + ), + style: STextStyles.desktopH3(context), + ), + const SizedBox(width: 12), + CoinTickerTag( + ticker: ref.watch( + pWalletCoin(widget.walletId).select((s) => s.ticker), + ), + ), + ], ), - ], + ), ), ), useSpacers: false, From 403000248383a40e54fde0126d0787c1ef316765 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 20 Nov 2025 12:35:40 -0600 Subject: [PATCH 62/80] ui(spl): desktop sol token wallet nav --- lib/pages/wallets_view/wallets_overview.dart | 83 ++++++-- .../desktop_expanding_solana_wallet_card.dart | 201 ++++++++++++++++++ lib/widgets/wallet_card.dart | 156 +++++++++++--- .../sub_widgets/wallet_info_row_balance.dart | 47 +++- .../wallet_info_row/wallet_info_row.dart | 40 +++- 5 files changed, 466 insertions(+), 61 deletions(-) create mode 100644 lib/pages_desktop_specific/my_stack_view/dialogs/desktop_expanding_solana_wallet_card.dart diff --git a/lib/pages/wallets_view/wallets_overview.dart b/lib/pages/wallets_view/wallets_overview.dart index d0cc2d12d..3878cfbce 100644 --- a/lib/pages/wallets_view/wallets_overview.dart +++ b/lib/pages/wallets_view/wallets_overview.dart @@ -8,6 +8,8 @@ * */ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; @@ -15,7 +17,8 @@ import 'package:isar_community/isar.dart'; import '../../app_config.dart'; import '../../models/add_wallet_list_entity/sub_classes/coin_entity.dart'; -import '../../models/isar/models/ethereum/eth_contract.dart'; +import '../../models/isar/models/contract.dart'; +import '../../pages_desktop_specific/my_stack_view/dialogs/desktop_expanding_solana_wallet_card.dart'; import '../../pages_desktop_specific/my_stack_view/dialogs/desktop_expanding_wallet_card.dart'; import '../../providers/providers.dart'; import '../../services/event_bus/events/wallet_added_event.dart'; @@ -23,6 +26,7 @@ import '../../services/event_bus/global_event_bus.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; import '../../utilities/constants.dart'; +import '../../utilities/default_spl_tokens.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; @@ -59,7 +63,7 @@ class WalletsOverview extends ConsumerStatefulWidget { ConsumerState createState() => _EthWalletsOverviewState(); } -typedef WalletListItemData = ({Wallet wallet, List contracts}); +typedef WalletListItemData = ({Wallet wallet, List contracts}); class _EthWalletsOverviewState extends ConsumerState { final isDesktop = Util.isDesktop; @@ -99,15 +103,13 @@ class _EthWalletsOverviewState extends ConsumerState { term, ); - final List contracts = []; + final List contracts = []; for (final contract in entry.value.contracts) { if (_elementContains(contract.name, term)) { contracts.add(contract); } else if (_elementContains(contract.symbol, term)) { contracts.add(contract); - } else if (_elementContains(contract.type.name, term)) { - contracts.add(contract); } else if (_elementContains(contract.address, term)) { contracts.add(contract); } @@ -133,7 +135,7 @@ class _EthWalletsOverviewState extends ConsumerState { if (widget.coin is Ethereum) { for (final data in walletsData) { - final List contracts = []; + final List contracts = []; final contractAddresses = ref.read( pWalletTokenAddresses(data.walletId), ); @@ -150,6 +152,51 @@ class _EthWalletsOverviewState extends ConsumerState { } } + // add tuple to list + wallets[data.walletId] = ( + wallet: ref.read(pWallets).getWallet(data.walletId), + contracts: contracts, + ); + } + } else if (widget.coin is Solana) { + // Ensure default Solana tokens are loaded into database. + final dbProvider = ref.read(mainDBProvider); + for (final defaultToken in DefaultSplTokens.list) { + final existingToken = dbProvider.getSplTokenSync(defaultToken.address); + if (existingToken == null) { + // Token not in database, add it asynchronously. + unawaited(dbProvider.putSplToken(defaultToken)); + } + } + + for (final data in walletsData) { + final List contracts = []; + final tokenMintAddresses = ref.read( + pWalletTokenAddresses(data.walletId), + ); + + // fetch each token + for (final tokenAddress in tokenMintAddresses) { + final token = dbProvider.getSplTokenSync(tokenAddress); + + // add it to list if it exists in DB or in default tokens + if (token != null) { + contracts.add(token); + } else { + // Try to find in default tokens. + try { + final defaultToken = DefaultSplTokens.list.firstWhere( + (t) => t.address == tokenAddress, + ); + contracts.add(defaultToken); + } catch (_) { + // Token not found anywhere. + // + // Might want to throw here or something. + } + } + } + // add tuple to list wallets[data.walletId] = ( wallet: ref.read(pWallets).getWallet(data.walletId), @@ -319,13 +366,23 @@ class _EthWalletsOverviewState extends ConsumerState { if (wallet.cryptoCurrency.hasTokenSupport) { if (isDesktop) { - return DesktopExpandingWalletCard( - key: Key( - "${wallet.walletId}_${entry.contracts.map((e) => e.address).join()}", - ), - data: entry, - navigatorState: widget.navigatorState!, - ); + if (wallet.cryptoCurrency is Solana) { + return DesktopExpandingSolanaWalletCard( + key: Key( + "${wallet.walletId}_${entry.contracts.map((e) => e.address).join()}", + ), + data: entry, + navigatorState: widget.navigatorState!, + ); + } else { + return DesktopExpandingWalletCard( + key: Key( + "${wallet.walletId}_${entry.contracts.map((e) => e.address).join()}", + ), + data: entry, + navigatorState: widget.navigatorState!, + ); + } } else { return MasterWalletCard( key: Key(wallet.walletId), diff --git a/lib/pages_desktop_specific/my_stack_view/dialogs/desktop_expanding_solana_wallet_card.dart b/lib/pages_desktop_specific/my_stack_view/dialogs/desktop_expanding_solana_wallet_card.dart new file mode 100644 index 000000000..db0afd695 --- /dev/null +++ b/lib/pages_desktop_specific/my_stack_view/dialogs/desktop_expanding_solana_wallet_card.dart @@ -0,0 +1,201 @@ +/* + * 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. + * Generated by Cypher Stack on 2025-11-20 + * + */ + +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../../../pages/wallets_view/wallets_overview.dart'; +import '../../../themes/stack_colors.dart'; +import '../../../utilities/assets.dart'; +import '../../../utilities/constants.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../widgets/animated_widgets/rotate_icon.dart'; +import '../../../widgets/expandable.dart'; +import '../../../widgets/rounded_white_container.dart'; +import '../../../widgets/wallet_card.dart'; +import '../../../widgets/wallet_info_row/sub_widgets/wallet_info_row_balance.dart'; +import '../../../widgets/wallet_info_row/sub_widgets/wallet_info_row_coin_icon.dart'; + +class DesktopExpandingSolanaWalletCard extends StatefulWidget { + const DesktopExpandingSolanaWalletCard({ + super.key, + required this.data, + required this.navigatorState, + }); + + final WalletListItemData data; + final NavigatorState navigatorState; + + @override + State createState() => + _DesktopExpandingSolanaWalletCardState(); +} + +class _DesktopExpandingSolanaWalletCardState + extends State { + final expandableController = ExpandableController(); + final rotateIconController = RotateIconController(); + final List tokenMintAddresses = []; + + @override + void initState() { + if (widget.data.wallet.cryptoCurrency.hasTokenSupport) { + tokenMintAddresses.addAll( + widget.data.contracts.map((e) => e.address), + ); + } + + super.initState(); + } + + @override + Widget build(BuildContext context) { + return RoundedWhiteContainer( + padding: EdgeInsets.zero, + borderColor: Theme.of(context).extension()!.backgroundAppBar, + child: Expandable( + initialState: widget.data.wallet.cryptoCurrency.hasTokenSupport + ? ExpandableState.expanded + : ExpandableState.collapsed, + controller: expandableController, + onExpandWillChange: (toState) { + if (toState == ExpandableState.expanded) { + rotateIconController.forward?.call(); + } else { + rotateIconController.reverse?.call(); + } + }, + header: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 14, + ), + child: Row( + children: [ + Expanded( + child: Row( + children: [ + Expanded( + flex: 2, + child: Row( + children: [ + WalletInfoCoinIcon( + coin: widget.data.wallet.info.coin, + ), + const SizedBox( + width: 12, + ), + Text( + widget.data.wallet.info.name, + style: STextStyles.desktopTextExtraSmall(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textDark, + ), + ), + ], + ), + ), + Expanded( + flex: 4, + child: WalletInfoRowBalance( + walletId: widget.data.wallet.walletId, + ), + ), + ], + ), + ), + MaterialButton( + padding: const EdgeInsets.all(5), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + minWidth: 32, + height: 32, + color: Theme.of(context) + .extension()! + .textFieldDefaultBG, + elevation: 0, + hoverElevation: 0, + disabledElevation: 0, + highlightElevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + onPressed: () { + if (expandableController.state == ExpandableState.collapsed) { + rotateIconController.forward?.call(); + } else { + rotateIconController.reverse?.call(); + } + expandableController.toggle?.call(); + }, + child: RotateIcon( + controller: rotateIconController, + icon: RotatedBox( + quarterTurns: 2, + child: SvgPicture.asset( + Assets.svg.chevronDown, + width: 14, + ), + ), + curve: Curves.easeInOut, + ), + ), + ], + ), + ), + body: ListView( + shrinkWrap: true, + primary: false, + children: [ + Container( + width: double.infinity, + height: 1, + color: + Theme.of(context).extension()!.backgroundAppBar, + ), + Padding( + padding: const EdgeInsets.only( + left: 32, + right: 14, + top: 14, + bottom: 14, + ), + child: SimpleWalletCard( + walletId: widget.data.wallet.walletId, + popPrevious: true, + desktopNavigatorState: widget.navigatorState, + ), + ), + ...tokenMintAddresses.map( + (e) => Padding( + padding: const EdgeInsets.only( + left: 32, + right: 14, + top: 14, + bottom: 14, + ), + child: SimpleWalletCard( + walletId: widget.data.wallet.walletId, + contractAddress: e, + popPrevious: true, + desktopNavigatorState: widget.navigatorState, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/wallet_card.dart b/lib/widgets/wallet_card.dart index ed49e5ebb..d545f3dae 100644 --- a/lib/widgets/wallet_card.dart +++ b/lib/widgets/wallet_card.dart @@ -14,6 +14,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../models/isar/models/ethereum/eth_contract.dart'; +import '../models/isar/models/solana/spl_token.dart'; import '../pages/token_view/token_view.dart'; import '../pages/wallet_view/wallet_view.dart'; import '../pages_desktop_specific/my_stack_view/wallet_view/desktop_token_view.dart'; @@ -25,9 +26,13 @@ import '../utilities/logger.dart'; import '../utilities/show_loading.dart'; import '../utilities/show_node_tor_settings_mismatch.dart'; import '../utilities/util.dart'; +import '../wallets/crypto_currency/coins/solana.dart'; import '../wallets/isar/providers/eth/current_token_wallet_provider.dart'; +import '../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; import '../wallets/wallet/impl/ethereum_wallet.dart'; +import '../wallets/wallet/impl/solana_wallet.dart'; import '../wallets/wallet/impl/sub_wallets/eth_token_wallet.dart'; +import '../wallets/wallet/impl/sub_wallets/solana_token_wallet.dart'; import '../wallets/wallet/intermediate/external_wallet.dart'; import '../wallets/wallet/wallet.dart'; import 'conditional_parent.dart'; @@ -50,7 +55,7 @@ class SimpleWalletCard extends ConsumerWidget { final bool popPrevious; final NavigatorState? desktopNavigatorState; - Future _loadTokenWallet( + Future _loadEthTokenWallet( BuildContext context, WidgetRef ref, Wallet wallet, @@ -91,6 +96,47 @@ class SimpleWalletCard extends ConsumerWidget { } } + Future _loadSolanaTokenWallet( + BuildContext context, + WidgetRef ref, + Wallet wallet, + SplToken token, + ) async { + final old = ref.read(solanaTokenServiceStateProvider); + // exit previous if there is one + unawaited(old?.exit()); + ref.read(solanaTokenServiceStateProvider.state).state = SolanaTokenWallet( + wallet as SolanaWallet, + token, + ); + + try { + await ref.read(pCurrentSolanaTokenWallet)!.init(); + return true; + } catch (_) { + await showDialog( + barrierDismissible: false, + context: context, + builder: (context) => BasicDialog( + title: "Failed to load token data", + desktopHeight: double.infinity, + desktopWidth: 450, + rightButton: PrimaryButton( + label: "OK", + onPressed: () { + Navigator.of(context).pop(); + Navigator.of(context).pop(); + if (desktopNavigatorState == null) { + Navigator.of(context).pop(); + } + }, + ), + ), + ); + return false; + } + } + void _openWallet(BuildContext context, WidgetRef ref) async { final nav = Navigator.of(context); @@ -141,40 +187,90 @@ class SimpleWalletCard extends ConsumerWidget { } if (contractAddress != null) { - final contract = - ref.read(mainDBProvider).getEthContractSync(contractAddress!)!; - - final success = await showLoading( - whileFuture: _loadTokenWallet( - desktopNavigatorState?.context ?? context, - ref, - wallet, - contract, - ), - context: desktopNavigatorState?.context ?? context, - opaqueBG: true, - message: "Loading ${contract.name}", - rootNavigator: Util.isDesktop, - ); + if (wallet.cryptoCurrency is Solana) { + // Handle Solana token. + final token = ref.read(mainDBProvider).getSplTokenSync(contractAddress!); - if (!success!) { - // TODO: show error dialog here? - Logging.instance.e( - "Failed to load token wallet for $contract", - ); - return; - } + if (token == null) { + Logging.instance.e( + "Failed to find Solana token with address: $contractAddress", + ); + return; + } - if (desktopNavigatorState != null) { - await desktopNavigatorState!.pushNamed( - DesktopTokenView.routeName, - arguments: walletId, + final success = await showLoading( + whileFuture: _loadSolanaTokenWallet( + desktopNavigatorState?.context ?? context, + ref, + wallet, + token, + ), + context: desktopNavigatorState?.context ?? context, + opaqueBG: true, + message: "Loading ${token.name}", + rootNavigator: Util.isDesktop, ); + + if (!success!) { + Logging.instance.e( + "Failed to load token wallet for $token", + ); + return; + } + + if (desktopNavigatorState != null) { + await desktopNavigatorState!.pushNamed( + DesktopTokenView.routeName, + arguments: walletId, + ); + } else { + await nav.pushNamed( + TokenView.routeName, + arguments: (walletId: walletId, popPrevious: !Util.isDesktop), + ); + } } else { - await nav.pushNamed( - TokenView.routeName, - arguments: (walletId: walletId, popPrevious: !Util.isDesktop), + // Handle Ethereum token (default). + final contract = ref.read(mainDBProvider).getEthContractSync(contractAddress!); + + if (contract == null) { + Logging.instance.e( + "Failed to find Ethereum contract with address: $contractAddress", + ); + return; + } + + final success = await showLoading( + whileFuture: _loadEthTokenWallet( + desktopNavigatorState?.context ?? context, + ref, + wallet, + contract, + ), + context: desktopNavigatorState?.context ?? context, + opaqueBG: true, + message: "Loading ${contract.name}", + rootNavigator: Util.isDesktop, ); + + if (!success!) { + Logging.instance.e( + "Failed to load token wallet for $contract", + ); + return; + } + + if (desktopNavigatorState != null) { + await desktopNavigatorState!.pushNamed( + DesktopTokenView.routeName, + arguments: walletId, + ); + } else { + await nav.pushNamed( + TokenView.routeName, + arguments: (walletId: walletId, popPrevious: !Util.isDesktop), + ); + } } } } diff --git a/lib/widgets/wallet_info_row/sub_widgets/wallet_info_row_balance.dart b/lib/widgets/wallet_info_row/sub_widgets/wallet_info_row_balance.dart index e88737d4e..e8c1c59c2 100644 --- a/lib/widgets/wallet_info_row/sub_widgets/wallet_info_row_balance.dart +++ b/lib/widgets/wallet_info_row/sub_widgets/wallet_info_row_balance.dart @@ -12,12 +12,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../db/isar/main_db.dart'; import '../../../models/isar/models/ethereum/eth_contract.dart'; +import '../../../models/isar/models/solana/spl_token.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/amount/amount.dart'; import '../../../utilities/amount/amount_formatter.dart'; import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; +import '../../../wallets/crypto_currency/coins/solana.dart'; import '../../../wallets/isar/providers/eth/token_balance_provider.dart'; +import '../../../wallets/isar/providers/solana/sol_token_balance_provider.dart'; import '../../../wallets/isar/providers/wallet_info_provider.dart'; class WalletInfoRowBalance extends ConsumerWidget { @@ -36,21 +39,49 @@ class WalletInfoRowBalance extends ConsumerWidget { final Amount totalBalance; EthContract? contract; + SplToken? splToken; + if (contractAddress == null) { totalBalance = info.cachedBalance.total + info.cachedBalanceSecondary.total + info.cachedBalanceTertiary.total; contract = null; + splToken = null; } else { - contract = MainDB.instance.getEthContractSync(contractAddress!)!; - totalBalance = ref - .watch( - pTokenBalance( - (walletId: walletId, contractAddress: contractAddress!), - ), - ) - .total; + // Check if it's a Solana wallet. + if (info.coin is Solana) { + splToken = MainDB.instance.getSplTokenSync(contractAddress!); + if (splToken != null) { + final solanaTokenInfo = ref + .watch( + pSolanaTokenWalletInfo( + (walletId: walletId, tokenMint: contractAddress!), + ), + ); + totalBalance = solanaTokenInfo.getCachedBalance().total; + } else { + // Token not yet in database, show zero balance. + totalBalance = Amount(rawValue: BigInt.zero, fractionDigits: 0); + } + contract = null; + } else { + // Ethereum token. + contract = MainDB.instance.getEthContractSync(contractAddress!); + if (contract != null) { + totalBalance = ref + .watch( + pTokenBalance( + (walletId: walletId, contractAddress: contractAddress!), + ), + ) + .total; + } else { + // Contract not yet in database, show zero balance. + totalBalance = Amount(rawValue: BigInt.zero, fractionDigits: 0); + } + splToken = null; + } } return Text( diff --git a/lib/widgets/wallet_info_row/wallet_info_row.dart b/lib/widgets/wallet_info_row/wallet_info_row.dart index 69aa9060e..5ea2add2b 100644 --- a/lib/widgets/wallet_info_row/wallet_info_row.dart +++ b/lib/widgets/wallet_info_row/wallet_info_row.dart @@ -11,11 +11,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../models/isar/models/contract.dart'; import '../../models/isar/models/ethereum/eth_contract.dart'; +import '../../models/isar/models/solana/spl_token.dart'; import '../../providers/providers.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; +import '../../wallets/crypto_currency/coins/solana.dart'; import '../../wallets/isar/providers/wallet_info_provider.dart'; import '../coin_ticker_tag.dart'; import '../custom_buttons/blue_text_button.dart'; @@ -39,14 +42,31 @@ class WalletInfoRow extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final wallet = ref.watch(pWallets).getWallet(walletId); + final walletInfo = ref.watch(pWalletInfo(walletId)); + + Contract? contract; + String? contractName; - EthContract? contract; if (contractAddress != null) { - contract = ref.watch( - mainDBProvider.select( - (value) => value.getEthContractSync(contractAddress!), - ), - ); + if (walletInfo.coin is Solana) { + // Solana token. + final splToken = ref.watch( + mainDBProvider.select( + (value) => value.getSplTokenSync(contractAddress!), + ), + ); + contract = splToken; + contractName = splToken?.name; + } else { + // Ethereum token. + final ethContract = ref.watch( + mainDBProvider.select( + (value) => value.getEthContractSync(contractAddress!), + ), + ); + contract = ethContract; + contractName = ethContract?.name; + } } if (Util.isDesktop) { @@ -65,11 +85,11 @@ class WalletInfoRow extends ConsumerWidget { contractAddress: contractAddress, ), const SizedBox(width: 12), - contract != null + contractName != null ? Row( children: [ Text( - contract.name, + contractName!, style: STextStyles.desktopTextExtraSmall( context, @@ -138,11 +158,11 @@ class WalletInfoRow extends ConsumerWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (contract != null) + if (contractName != null) Row( children: [ Text( - contract.name, + contractName!, style: STextStyles.titleBold12(context), ), const SizedBox(width: 4), From 61e0102298c10c122404cdc221f32f29cf3923b8 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 20 Nov 2025 13:46:01 -0600 Subject: [PATCH 63/80] ui(spl): mobile sol token wallet nav fix(spl): fix navigation to token sub-wallet --- .../sub_widgets/wallet_list_item.dart | 20 +++++++++- lib/widgets/wallet_card.dart | 40 ++++++++++--------- 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/lib/pages/wallets_view/sub_widgets/wallet_list_item.dart b/lib/pages/wallets_view/sub_widgets/wallet_list_item.dart index 64d9ecbc9..42955e006 100644 --- a/lib/pages/wallets_view/sub_widgets/wallet_list_item.dart +++ b/lib/pages/wallets_view/sub_widgets/wallet_list_item.dart @@ -24,7 +24,9 @@ import '../../../utilities/show_loading.dart'; import '../../../utilities/show_node_tor_settings_mismatch.dart'; import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; +import '../../../wallets/crypto_currency/coins/solana.dart'; import '../../../wallets/crypto_currency/crypto_currency.dart'; +import '../../../wallets/isar/providers/wallet_info_provider.dart'; import '../../../wallets/wallet/intermediate/external_wallet.dart'; import '../../../widgets/dialogs/tor_warning_dialog.dart'; import '../../../widgets/rounded_white_container.dart'; @@ -80,7 +82,23 @@ class WalletListItem extends ConsumerWidget { } } - if (walletCount == 1 && coin is! Ethereum) { + // Check if we should show the wallets overview or open wallet directly. + bool shouldShowWalletsOverview = walletCount > 1 || coin is Ethereum; + + // For Solana and other token-supporting coins, check if any wallet has tokens. + if (!shouldShowWalletsOverview && coin.hasTokenSupport) { + final wallet = ref + .read(pWallets) + .wallets + .firstWhere((e) => e.info.coin == coin); + + final tokenAddresses = ref.read(pWalletTokenAddresses(wallet.walletId)); + if (tokenAddresses.isNotEmpty) { + shouldShowWalletsOverview = true; + } + } + + if (walletCount == 1 && !shouldShowWalletsOverview) { final wallet = ref .read(pWallets) .wallets diff --git a/lib/widgets/wallet_card.dart b/lib/widgets/wallet_card.dart index d545f3dae..11fec0932 100644 --- a/lib/widgets/wallet_card.dart +++ b/lib/widgets/wallet_card.dart @@ -15,8 +15,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../models/isar/models/ethereum/eth_contract.dart'; import '../models/isar/models/solana/spl_token.dart'; +import '../pages/token_view/sol_token_view.dart'; import '../pages/token_view/token_view.dart'; import '../pages/wallet_view/wallet_view.dart'; +import '../pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart'; import '../pages_desktop_specific/my_stack_view/wallet_view/desktop_token_view.dart'; import '../pages_desktop_specific/my_stack_view/wallet_view/desktop_wallet_view.dart'; import '../providers/db/main_db_provider.dart'; @@ -170,20 +172,22 @@ class SimpleWalletCard extends ConsumerWidget { ); if (popPrevious) nav.pop(); - if (desktopNavigatorState != null) { - unawaited( - desktopNavigatorState!.pushNamed( - DesktopWalletView.routeName, - arguments: walletId, - ), - ); - } else { - unawaited( - nav.pushNamed( - WalletView.routeName, - arguments: walletId, - ), - ); + if (contractAddress == null) { + if (desktopNavigatorState != null) { + unawaited( + desktopNavigatorState!.pushNamed( + DesktopWalletView.routeName, + arguments: walletId, + ), + ); + } else { + unawaited( + nav.pushNamed( + WalletView.routeName, + arguments: walletId, + ), + ); + } } if (contractAddress != null) { @@ -220,13 +224,13 @@ class SimpleWalletCard extends ConsumerWidget { if (desktopNavigatorState != null) { await desktopNavigatorState!.pushNamed( - DesktopTokenView.routeName, - arguments: walletId, + DesktopSolTokenView.routeName, + arguments: (walletId: walletId, tokenMint: contractAddress!), ); } else { await nav.pushNamed( - TokenView.routeName, - arguments: (walletId: walletId, popPrevious: !Util.isDesktop), + SolTokenView.routeName, + arguments: (walletId: walletId, tokenMint: contractAddress!), ); } } else { From 977222dce7c85247c38b820fce9b60ccbcee979b Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 20 Nov 2025 22:27:52 -0600 Subject: [PATCH 64/80] feat(spl): fee selection TODO dynamic estimation via api --- lib/pages/send_view/sol_token_send_view.dart | 163 ++++++++++++++---- .../sub_widgets/desktop_send_fee_form.dart | 5 +- lib/wallets/wallet/impl/solana_wallet.dart | 45 +++-- 3 files changed, 169 insertions(+), 44 deletions(-) diff --git a/lib/pages/send_view/sol_token_send_view.dart b/lib/pages/send_view/sol_token_send_view.dart index 396f7f7e2..c94528fd3 100644 --- a/lib/pages/send_view/sol_token_send_view.dart +++ b/lib/pages/send_view/sol_token_send_view.dart @@ -14,6 +14,7 @@ import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import '../../models/isar/models/isar_models.dart'; import '../../models/send_view_auto_fill_data.dart'; @@ -28,7 +29,9 @@ import '../../utilities/amount/amount_formatter.dart'; import '../../utilities/amount/amount_input_formatter.dart'; import '../../utilities/barcode_scanner_interface.dart'; import '../../utilities/clipboard_interface.dart'; +import '../../utilities/assets.dart'; import '../../utilities/constants.dart'; +import '../../utilities/enums/fee_rate_type_enum.dart'; import '../../utilities/logger.dart'; import '../../utilities/prefs.dart'; import '../../utilities/text_styles.dart'; @@ -340,10 +343,45 @@ class _SolTokenSendViewState extends ConsumerState { } Future calculateFees() async { - // TODO: Implement Solana fee calculation. - // For now, return a placeholder fee - cachedFees = "0.000005 SOL"; - return cachedFees; + try { + final wallet = ref.read(pCurrentSolanaTokenWallet); + if (wallet == null) { + return "0.000005 SOL"; + } + + final feeObject = await wallet.fees; + + late final BigInt feeRate; + + switch (ref.read(feeRateTypeMobileStateProvider.state).state) { + case FeeRateType.fast: + feeRate = feeObject.fast; + break; + case FeeRateType.average: + feeRate = feeObject.medium; + break; + case FeeRateType.slow: + feeRate = feeObject.slow; + break; + default: + feeRate = BigInt.from(-1); + } + + final Amount fee = await wallet.estimateFeeFor(Amount.zero, feeRate); + cachedFees = ref + .read(pAmountFormatter(Solana(CryptoCurrencyNetwork.main))) + .format(fee, withUnitName: true, indicatePrecisionLoss: false); + + return cachedFees; + } catch (e, s) { + Logging.instance.w( + "Failed to calculate Solana token fees: ", + error: e, + stackTrace: s, + ); + // Return minimum fee as fallback. + return "0.000005 SOL"; + } } Future _previewTransaction() async { @@ -522,6 +560,7 @@ class _SolTokenSendViewState extends ConsumerState { @override void initState() { ref.refresh(feeSheetSessionCacheProvider); + ref.read(feeRateTypeMobileStateProvider.state).state = FeeRateType.slow; _calculateFeesFuture = calculateFees(); _data = widget.autoFillData; @@ -1114,38 +1153,100 @@ class _SolTokenSendViewState extends ConsumerState { ), ), onPressed: () { - // TODO: Implement fee selection for Solana. + showModalBottomSheet( + backgroundColor: Colors.transparent, + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(20), + ), + ), + builder: (_) => + TransactionFeeSelectionSheet( + walletId: walletId, + isToken: true, + amount: + (Decimal.tryParse( + cryptoAmountController + .text, + ) ?? + Decimal.zero) + .toAmount( + fractionDigits: + tokenWallet + .tokenDecimals, + ), + updateChosen: (String fee) { + setState(() { + _calculateFeesFuture = Future( + () => fee, + ); + }); + }, + ), + ); }, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - FutureBuilder( - future: _calculateFeesFuture, - builder: (context, snapshot) { - if (snapshot.connectionState == - ConnectionState.done && - snapshot.hasData) { - return Text( - "~${snapshot.data!}", - style: STextStyles.itemSubtitle( - context, - ), - ); - } else { - return AnimatedText( - stringsToLoopThrough: const [ - "Calculating", - "Calculating.", - "Calculating..", - "Calculating...", - ], - style: STextStyles.itemSubtitle( - context, - ), - ); - } - }, + Row( + children: [ + Text( + ref + .watch( + feeRateTypeMobileStateProvider + .state, + ) + .state + .prettyName, + style: STextStyles.itemSubtitle12( + context, + ), + ), + const SizedBox(width: 10), + FutureBuilder( + future: _calculateFeesFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == + ConnectionState.done && + snapshot.hasData) { + return Text( + "~${snapshot.data!}", + style: + STextStyles.itemSubtitle( + context, + ), + ); + } else { + return AnimatedText( + stringsToLoopThrough: + const [ + "Calculating", + "Calculating.", + "Calculating..", + "Calculating...", + ], + style: + STextStyles.itemSubtitle( + context, + ), + ); + } + }, + ), + ], + ), + SvgPicture.asset( + Assets.svg.chevronDown, + width: 8, + height: 4, + colorFilter: ColorFilter.mode( + Theme.of(context) + .extension()! + .textSubtitle2, + BlendMode.srcIn, + ), ), ], ), diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart index 9779f4036..f67750406 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart @@ -77,6 +77,7 @@ class _DesktopSendFeeFormState extends ConsumerState { Widget build(BuildContext context) { final canEditFees = isEth || + cryptoCurrency is Solana || (cryptoCurrency is ElectrumXCurrencyInterface && !(((cryptoCurrency is Firo) && (ref.watch(publicPrivateBalanceStateProvider.state).state == @@ -211,7 +212,7 @@ class _DesktopSendFeeFormState extends ConsumerState { .estimateFeeFor(amount, feeRate); } } else { - // TODO: Implement fee estimation for Solana tokens. + // Token fee estimation (works for ERC20 and SPL tokens). try { final tokenWallet = ref.read( pCurrentTokenWallet, @@ -223,7 +224,7 @@ class _DesktopSendFeeFormState extends ConsumerState { .average[amount] = fee; } catch (_) { - // Token wallet not available (Solana). + // Token wallet not available. debugPrint("Token fee estimation not available"); } } diff --git a/lib/wallets/wallet/impl/solana_wallet.dart b/lib/wallets/wallet/impl/solana_wallet.dart index 6caa87516..b7a49be28 100644 --- a/lib/wallets/wallet/impl/solana_wallet.dart +++ b/lib/wallets/wallet/impl/solana_wallet.dart @@ -289,35 +289,58 @@ class SolanaWallet extends Bip39Wallet { ); } - final fee = await _getEstimatedNetworkFee(amount); - if (fee == null) { - throw Exception("Failed to get fees, please check your node connection."); - } - - return Amount(rawValue: fee, fractionDigits: cryptoCurrency.fractionDigits); + // The feeRate parameter contains the total fee amount to use. + // For Solana, this is already calculated based on priority tier. + // Simply return it as the fee estimate. + return Amount(rawValue: feeRate, fractionDigits: cryptoCurrency.fractionDigits); } @override Future get fees async { _checkClient(); - final fee = await _getEstimatedNetworkFee( + final baseFee = await _getEstimatedNetworkFee( Amount.fromDecimal( Decimal.one, // 1 SOL. fractionDigits: cryptoCurrency.fractionDigits, ), ); - if (fee == null) { + if (baseFee == null) { throw Exception("Failed to get fees, please check your node connection."); } + // Differentiate fees by tier using multipliers: + // Base fee is typically around 5000 lamports. + // Slow: minimum 5000 lamports. + // Average: base fee * 1.5 (but not less than slow). + // Fast: base fee * 2.0 (but not less than average). + // Ensure all fees stay within bounds: 5000-1000000 lamports. + const minFeeBig = 5000; + const maxFeeBig = 1000000; + + // Calculate tier fees with multipliers. + final slowFee = baseFee; // Use base fee for slow. + final averageFee = (baseFee * BigInt.from(3)) ~/ BigInt.from(2); // 1.5x. + final fastFee = baseFee * BigInt.from(2); // 2.0x. + + // Clamp all fees to the allowed range. + final _clamp = (BigInt value) { + if (value < BigInt.from(minFeeBig)) return BigInt.from(minFeeBig); + if (value > BigInt.from(maxFeeBig)) return BigInt.from(maxFeeBig); + return value; + }; + + final clampedSlow = _clamp(slowFee); + final clampedAverage = _clamp(averageFee); + final clampedFast = _clamp(fastFee); + return FeeObject( numberOfBlocksFast: 1, numberOfBlocksAverage: 1, numberOfBlocksSlow: 1, - fast: fee, - medium: fee, - slow: fee, + fast: clampedFast, + medium: clampedAverage, + slow: clampedSlow, ); } From fe1fdf6eaeb91fe74f15bab22ec8c03b0f697a1b Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 20 Nov 2025 22:37:03 -0600 Subject: [PATCH 65/80] fix(spl): mobile fee selection ui fix --- lib/pages/send_view/sol_token_send_view.dart | 5 ++- .../transaction_fee_selection_sheet.dart | 34 ++++++++++++++----- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/lib/pages/send_view/sol_token_send_view.dart b/lib/pages/send_view/sol_token_send_view.dart index c94528fd3..11731c30d 100644 --- a/lib/pages/send_view/sol_token_send_view.dart +++ b/lib/pages/send_view/sol_token_send_view.dart @@ -560,7 +560,10 @@ class _SolTokenSendViewState extends ConsumerState { @override void initState() { ref.refresh(feeSheetSessionCacheProvider); - ref.read(feeRateTypeMobileStateProvider.state).state = FeeRateType.slow; + + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(feeRateTypeMobileStateProvider.state).state = FeeRateType.slow; + }); _calculateFeesFuture = calculateFees(); _data = widget.autoFillData; diff --git a/lib/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart b/lib/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart index 8c165ccdb..5d586ae9f 100644 --- a/lib/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart +++ b/lib/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart @@ -1,4 +1,4 @@ -/* +/* * This file is part of Stack Wallet. * * Copyright (c) 2023 Cypher Stack @@ -28,6 +28,7 @@ import '../../../wallets/isar/providers/eth/current_token_wallet_provider.dart'; import '../../../wallets/isar/providers/wallet_info_provider.dart'; import '../../../wallets/wallet/impl/firo_wallet.dart'; import '../../../wallets/wallet/intermediate/cryptonote_wallet.dart'; +import '../../../wallets/wallet/wallet.dart'; import '../../../wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart'; import '../../../widgets/animated_text.dart'; @@ -115,8 +116,13 @@ class _TransactionFeeSelectionSheetState .estimateFeeFor(amount, feeRate); } } else { - final tokenWallet = ref.read(pCurrentTokenWallet)!; - final fee = await tokenWallet.estimateFeeFor(amount, feeRate); + final Wallet wallet; + if (coin is Ethereum) { + wallet = ref.read(pCurrentTokenWallet)!; + } else { + wallet = ref.read(pWallets).getWallet(walletId); + } + final fee = await wallet.estimateFeeFor(amount, feeRate); ref.read(feeSheetSessionCacheProvider).fast[amount] = fee; } } @@ -151,8 +157,13 @@ class _TransactionFeeSelectionSheetState await wallet.estimateFeeFor(amount, feeRate); } } else { - final tokenWallet = ref.read(pCurrentTokenWallet)!; - final fee = await tokenWallet.estimateFeeFor(amount, feeRate); + final Wallet wallet; + if (coin is Ethereum) { + wallet = ref.read(pCurrentTokenWallet)!; + } else { + wallet = ref.read(pWallets).getWallet(walletId); + } + final fee = await wallet.estimateFeeFor(amount, feeRate); ref.read(feeSheetSessionCacheProvider).average[amount] = fee; } } @@ -187,8 +198,13 @@ class _TransactionFeeSelectionSheetState .estimateFeeFor(amount, feeRate); } } else { - final tokenWallet = ref.read(pCurrentTokenWallet)!; - final fee = await tokenWallet.estimateFeeFor(amount, feeRate); + final Wallet wallet; + if (coin is Ethereum) { + wallet = ref.read(pCurrentTokenWallet)!; + } else { + wallet = ref.read(pWallets).getWallet(walletId); + } + final fee = await wallet.estimateFeeFor(amount, feeRate); ref.read(feeSheetSessionCacheProvider).slow[amount] = fee; } } @@ -269,7 +285,9 @@ class _TransactionFeeSelectionSheetState const SizedBox(height: 36), FutureBuilder( future: widget.isToken - ? ref.read(pCurrentTokenWallet)!.fees + ? (coin is Ethereum + ? ref.read(pCurrentTokenWallet)!.fees + : wallet.fees) : wallet.fees, builder: (context, AsyncSnapshot snapshot) { if (snapshot.connectionState == ConnectionState.done && From 6049da322919cc3cba5c41c9a0a10a90e881f5cf Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 21 Nov 2025 10:49:57 -0600 Subject: [PATCH 66/80] ui(spl): show tokens after sol wallet creation or restore on mobile --- .../restore_wallet_view/restore_wallet_view.dart | 4 +++- .../verify_recovery_phrase_view.dart | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart b/lib/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart index 55016b713..a1ea19c40 100644 --- a/lib/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart +++ b/lib/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart @@ -40,6 +40,8 @@ import '../../../utilities/logger.dart'; import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; import '../../../wallets/crypto_currency/crypto_currency.dart'; +import '../../../wallets/crypto_currency/coins/ethereum.dart'; +import '../../../wallets/crypto_currency/coins/solana.dart'; import '../../../wallets/isar/models/wallet_info.dart'; import '../../../wallets/wallet/impl/epiccash_wallet.dart'; import '../../../wallets/wallet/impl/mimblewimblecoin_wallet.dart'; @@ -412,7 +414,7 @@ class _RestoreWalletViewState extends ConsumerState { (route) => false, ), ); - if (info.coin is Ethereum) { + if (info.coin is Ethereum || info.coin is Solana) { unawaited( Navigator.of(context).pushNamed( EditWalletTokensView.routeName, diff --git a/lib/pages/add_wallet_views/verify_recovery_phrase_view/verify_recovery_phrase_view.dart b/lib/pages/add_wallet_views/verify_recovery_phrase_view/verify_recovery_phrase_view.dart index c00dab02e..2b9802b0d 100644 --- a/lib/pages/add_wallet_views/verify_recovery_phrase_view/verify_recovery_phrase_view.dart +++ b/lib/pages/add_wallet_views/verify_recovery_phrase_view/verify_recovery_phrase_view.dart @@ -31,6 +31,8 @@ import '../../../utilities/show_loading.dart'; import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; import '../../../wallets/crypto_currency/crypto_currency.dart'; +import '../../../wallets/crypto_currency/coins/ethereum.dart'; +import '../../../wallets/crypto_currency/coins/solana.dart'; import '../../../wallets/crypto_currency/intermediate/bip39_hd_currency.dart'; import '../../../wallets/isar/models/wallet_info.dart'; import '../../../wallets/wallet/impl/epiccash_wallet.dart'; @@ -356,7 +358,7 @@ class _VerifyRecoveryPhraseViewState Navigator.of( context, ).popUntil(ModalRoute.withName(DesktopHomeView.routeName)); - if (_coin is Ethereum) { + if (_coin is Ethereum || _coin is Solana) { unawaited( Navigator.of(context).pushNamed( EditWalletTokensView.routeName, @@ -376,7 +378,7 @@ class _VerifyRecoveryPhraseViewState context, ).pushNamedAndRemoveUntil(HomeView.routeName, (route) => false), ); - if (_coin is Ethereum) { + if (_coin is Ethereum || _coin is Solana) { WidgetsBinding.instance.addPostFrameCallback((_) { ref .read(pNavKey) From 553c2a84e506c419ba8b81758e5c41f15c7fe90b Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 21 Nov 2025 14:27:44 -0600 Subject: [PATCH 67/80] fix(spl): delegate chain refresh to parent wallet --- lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart b/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart index 3a35a4911..8ae30e3c8 100644 --- a/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart +++ b/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart @@ -666,7 +666,7 @@ class SolanaTokenWallet extends Wallet { @override Future updateChainHeight() async { - // TODO: Get latest Solana block height. + await parentSolanaWallet.updateChainHeight(); } @override From 9887fd64b275a709ef7e17faee0ef3613fffe777 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 21 Nov 2025 14:33:36 -0600 Subject: [PATCH 68/80] fix(spl): use sol's address validation for tokens, too --- lib/pages/send_view/sol_token_send_view.dart | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/lib/pages/send_view/sol_token_send_view.dart b/lib/pages/send_view/sol_token_send_view.dart index 11731c30d..62dfbbc22 100644 --- a/lib/pages/send_view/sol_token_send_view.dart +++ b/lib/pages/send_view/sol_token_send_view.dart @@ -322,9 +322,7 @@ class _SolTokenSendViewState extends ConsumerState { return null; } if (address.isNotEmpty) { - // TODO: Implement Solana address validation. - // For now, perform basic length check (44 chars is typical for Solana addresses). - if (address.length < 32 || address.length > 44) { + if (!Solana(CryptoCurrencyNetwork.main).validateAddress(address)) { return "Invalid address"; } } @@ -332,12 +330,10 @@ class _SolTokenSendViewState extends ConsumerState { } void _updatePreviewButtonState(String? address, Amount? amount) { - // TODO: Implement Solana address validation. final isValidAddress = address != null && address.isNotEmpty && - address.length >= 32 && - address.length <= 44; + Solana(CryptoCurrencyNetwork.main).validateAddress(address); ref.read(previewTokenTxButtonStateProvider.state).state = (isValidAddress && amount != null && amount > Amount.zero); } From 5b97929fbe53d00ae2ce49c273e5fb984903fc97 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 21 Nov 2025 15:02:50 -0600 Subject: [PATCH 69/80] fix(spl): init rpc even when you skip opening the parent wallet applicable if the user selects "only sync selected wallets" --- lib/wallets/wallet/impl/solana_wallet.dart | 27 ++++++++++--------- .../impl/sub_wallets/solana_token_wallet.dart | 4 ++- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/lib/wallets/wallet/impl/solana_wallet.dart b/lib/wallets/wallet/impl/solana_wallet.dart index b7a49be28..4e0882a3e 100644 --- a/lib/wallets/wallet/impl/solana_wallet.dart +++ b/lib/wallets/wallet/impl/solana_wallet.dart @@ -75,13 +75,13 @@ class SolanaWallet extends Bip39Wallet { } Future _getCurrentBalanceInLamports() async { - _checkClient(); + checkClient(); final balance = await _rpcClient?.getBalance((await _getKeyPair()).address); return BigInt.from(balance!.value); } Future _getEstimatedNetworkFee(Amount transferAmount) async { - _checkClient(); + checkClient(); final latestBlockhash = await _rpcClient?.getLatestBlockhash(); final pubKey = (await _getKeyPair()).publicKey; @@ -134,7 +134,7 @@ class SolanaWallet extends Bip39Wallet { @override Future prepareSend({required TxData txData}) async { try { - _checkClient(); + checkClient(); if (txData.recipients == null || txData.recipients!.length != 1) { throw Exception("$runtimeType prepareSend requires 1 recipient"); @@ -195,7 +195,7 @@ class SolanaWallet extends Bip39Wallet { @override Future confirmSend({required TxData txData}) async { try { - _checkClient(); + checkClient(); final keyPair = await _getKeyPair(); final recipientAccount = txData.recipients!.first; @@ -280,7 +280,7 @@ class SolanaWallet extends Bip39Wallet { @override Future estimateFeeFor(Amount amount, BigInt feeRate) async { - _checkClient(); + checkClient(); if (info.cachedBalance.spendable.raw == BigInt.zero) { return Amount( @@ -292,12 +292,15 @@ class SolanaWallet extends Bip39Wallet { // The feeRate parameter contains the total fee amount to use. // For Solana, this is already calculated based on priority tier. // Simply return it as the fee estimate. - return Amount(rawValue: feeRate, fractionDigits: cryptoCurrency.fractionDigits); + return Amount( + rawValue: feeRate, + fractionDigits: cryptoCurrency.fractionDigits, + ); } @override Future get fees async { - _checkClient(); + checkClient(); final baseFee = await _getEstimatedNetworkFee( Amount.fromDecimal( @@ -348,7 +351,7 @@ class SolanaWallet extends Bip39Wallet { Future pingCheck() async { String? health; try { - _checkClient(); + checkClient(); health = await _rpcClient?.getHealth(); return health != null; } catch (e, s) { @@ -387,7 +390,7 @@ class SolanaWallet extends Bip39Wallet { @override Future updateBalance() async { - _checkClient(); + checkClient(); try { final address = await getCurrentReceivingAddress(); @@ -437,7 +440,7 @@ class SolanaWallet extends Bip39Wallet { @override Future updateChainHeight() async { try { - _checkClient(); + checkClient(); final int blockHeight = await _rpcClient?.getSlot() ?? 0; // TODO [prio=low]: Revisit null condition. @@ -478,7 +481,7 @@ class SolanaWallet extends Bip39Wallet { @override Future updateTransactions() async { try { - _checkClient(); + checkClient(); final transactionsList = await _rpcClient?.getTransactionsList( (await _getKeyPair()).publicKey, @@ -665,7 +668,7 @@ class SolanaWallet extends Bip39Wallet { } /// Make sure the Solana RpcClient uses Tor if it's enabled. - void _checkClient() { + void checkClient() { final node = getCurrentNode(); final netOption = TorPlainNetworkOption.fromNodeData( diff --git a/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart b/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart index 8ae30e3c8..84555c200 100644 --- a/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart +++ b/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart @@ -77,7 +77,9 @@ class SolanaTokenWallet extends Wallet { @override Future init() async { await super.init(); - // TODO: Initialize token account address derivation. + + parentSolanaWallet.checkClient(); + await Future.delayed(const Duration(milliseconds: 100)); } From 75427be65fd2a0314505e260cb69e815b4e1622c Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 21 Nov 2025 17:21:31 -0600 Subject: [PATCH 70/80] feat(spl): implement Token-2022 transfers --- lib/services/solana/solana_token_api.dart | 41 +++ .../impl/sub_wallets/solana_token_wallet.dart | 329 +++++++++++++++--- 2 files changed, 330 insertions(+), 40 deletions(-) diff --git a/lib/services/solana/solana_token_api.dart b/lib/services/solana/solana_token_api.dart index b5a40272f..ac1bdab6c 100644 --- a/lib/services/solana/solana_token_api.dart +++ b/lib/services/solana/solana_token_api.dart @@ -532,6 +532,47 @@ class SolanaTokenAPI { } } + /// Detect which token program owns a mint address. + /// + /// Queries the RPC to get the mint account info and checks which program owns it. + /// This is needed to determine whether to use standard SPL Token instructions + /// or Token-2022 (Token Extensions) instructions for transfers. + /// + /// Returns: "spl" for standard SPL Token, "token2022" for Token Extensions, or null if detection fails. + Future getTokenProgramType(String mintAddress) async { + try { + _checkClient(); + + // Query the mint account to check its owner program. + final response = await _rpcClient!.getAccountInfo( + mintAddress, + encoding: Encoding.jsonParsed, + ); + + if (response.value == null) { + return null; + } + + final owner = response.value!.owner; + + // Check which program owns this mint. + // SPL Token: TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA + // Token-2022: TokenzQdBNbLvnVCrqtsvQQrXTVkDkAydS7d5xgqfnb + if (owner == 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA') { + return 'spl'; + } + if (owner.startsWith('Token') && owner != 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA') { + print('[SOLANA_TOKEN_API] Detected Token-2022 variant: $owner'); + return 'token2022'; + } + + return null; + } catch (e) { + print('[SOLANA_TOKEN_API] Error detecting token program: $e'); + return null; + } + } + /// Derive the metadata PDA for a given mint address. /// /// This is a temporary implementation that queries known metadata endpoints. diff --git a/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart b/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart index 84555c200..d7dfcd966 100644 --- a/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart +++ b/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart @@ -136,6 +136,21 @@ class SolanaTokenWallet extends Wallet { ); } + // Validate sender token account exists and has proper data. + try { + final accountInfo = await rpcClient.getAccountInfo( + senderTokenAccount, + encoding: Encoding.jsonParsed, + ); + if (accountInfo.value == null) { + throw Exception( + "Sender token account $senderTokenAccount not found on-chain", + ); + } + } catch (e) { + throw Exception("Failed to validate sender token account: $e"); + } + // Get latest block hash (used internally by RPC client). await rpcClient.getLatestBlockhash(); @@ -154,10 +169,38 @@ class SolanaTokenWallet extends Wallet { ); } - // Log the determined token account for debugging. - Logging.instance.i( - "$runtimeType prepareSend - recipient token account: $recipientTokenAccount", - ); + try { + final recipientAccountInfo = await rpcClient.getAccountInfo( + recipientTokenAccount, + encoding: Encoding.jsonParsed, + ); + if (recipientAccountInfo.value == null) { + throw Exception( + "Recipient token account $recipientTokenAccount does not exist on-chain. " + "The recipient must initialize their token account before receiving tokens. " + "You can ask the recipient to accept the token in their wallet app first.", + ); + } + + final accountData = recipientAccountInfo.value!; + + // Verify account is owned by token program (not System Program). + if (accountData.owner == '11111111111111111111111111111111') { + throw Exception( + "Recipient token account $recipientTokenAccount is owned by the System Program, " + "not a token program. The account may not be a valid token account.", + ); + } + } catch (e) { + if (e.toString().contains("does not exist") || + e.toString().contains("not owned by")) { + rethrow; // Re-throw our validation errors. + } + throw Exception( + "Failed to validate recipient token account: $e. " + "Ensure the recipient has initialized their token account.", + ); + } // Build SPL token tx instruction. final senderTokenAccountKey = Ed25519HDPublicKey.fromBase58( @@ -166,17 +209,55 @@ class SolanaTokenWallet extends Wallet { final recipientTokenAccountKey = Ed25519HDPublicKey.fromBase58( recipientTokenAccount, ); + final mintPubkey = Ed25519HDPublicKey.fromBase58(tokenMint); + + // Query the actual token program owner (important for Token-2022 variants). + String tokenProgramId; + try { + final mintInfo = await rpcClient.getAccountInfo( + tokenMint, + encoding: Encoding.jsonParsed, + ); + if (mintInfo.value != null) { + tokenProgramId = mintInfo.value!.owner; + Logging.instance.i( + "$runtimeType prepareSend: Token program owner = $tokenProgramId for mint $tokenMint", + ); + } else { + // Fallback to SPL Token. + tokenProgramId = 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA'; + Logging.instance.w( + "$runtimeType prepareSend: Could not query mint owner, using SPL Token", + ); + } + } catch (e) { + // Fallback to SPL Token on error. + tokenProgramId = 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA'; + Logging.instance.w( + "$runtimeType prepareSend: Error querying mint owner: $e, using SPL Token", + ); + } + + // Build the transfer instruction (will be rebuilt in confirmSend with updated blockhash). + // Determine which token program type to use based on the queried owner. + final TokenProgramType tokenProgram = + tokenProgramId != 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA' + && tokenProgramId.startsWith('Token') + ? TokenProgramType.token2022Program + : TokenProgramType.tokenProgram; - // Build the transfer instruction (validated later in confirmSend). // ignore: unused_local_variable - final instruction = TokenInstruction.transfer( + final instruction = TokenInstruction.transferChecked( source: senderTokenAccountKey, destination: recipientTokenAccountKey, + mint: mintPubkey, owner: keyPair.publicKey, + decimals: tokenDecimals, amount: txData.amount!.raw.toInt(), + tokenProgram: tokenProgram, ); - // Estimate fee using RPC call. + // Estimate fee. final feeEstimate = await _getEstimatedTokenTransferFee( senderTokenAccountKey: senderTokenAccountKey, @@ -237,8 +318,6 @@ class SolanaTokenWallet extends Wallet { throw Exception("Token account not found"); } - // Get latest block hash (again, in case it expired). - // (RPC client handles blockhash internally) await rpcClient.getLatestBlockhash(); // Reuse the recipient token account from prepareSend (already looked up once). @@ -263,12 +342,54 @@ class SolanaTokenWallet extends Wallet { final recipientTokenAccountKey = Ed25519HDPublicKey.fromBase58( recipientTokenAccount, ); + final mintPubkey = Ed25519HDPublicKey.fromBase58(tokenMint); + + // Query the actual token program owner (important for Token-2022 variants). + String tokenProgramId; + try { + final mintInfo = await rpcClient.getAccountInfo( + tokenMint, + encoding: Encoding.jsonParsed, + ); + if (mintInfo.value != null) { + tokenProgramId = mintInfo.value!.owner; + Logging.instance.i( + "$runtimeType confirmSend: Token program owner = $tokenProgramId for mint $tokenMint", + ); + } else { + // Fallback to SPL Token. + tokenProgramId = 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA'; + Logging.instance.w( + "$runtimeType confirmSend: Could not query mint owner, using SPL Token", + ); + } + } catch (e) { + // Fallback to SPL Token on error. + tokenProgramId = 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA'; + Logging.instance.w( + "$runtimeType confirmSend: Error querying mint owner: $e, using SPL Token", + ); + } + + // Build the TransferChecked instruction. + final TokenProgramType tokenProgram = + tokenProgramId != 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA' + && tokenProgramId.startsWith('Token') // Token-2022 variant. + ? TokenProgramType.token2022Program + : TokenProgramType.tokenProgram; - final instruction = TokenInstruction.transfer( + final instruction = TokenInstruction.transferChecked( source: senderTokenAccountKey, destination: recipientTokenAccountKey, + mint: mintPubkey, owner: keyPair.publicKey, + decimals: tokenDecimals, amount: txData.amount!.raw.toInt(), + tokenProgram: tokenProgram, + ); + + Logging.instance.i( + "$runtimeType confirmSend: Built TransferChecked instruction for token program $tokenProgramId", ); // Create message. @@ -728,11 +849,19 @@ class SolanaTokenWallet extends Wallet { ); if (result.value.isEmpty) { + Logging.instance.w( + "$runtimeType _findTokenAccount: No token account found for " + "owner=$ownerAddress, mint=$mint", + ); return null; } - // Return the first token account address - return result.value.first.pubkey; + final tokenAccountAddress = result.value.first.pubkey; + Logging.instance.i( + "$runtimeType _findTokenAccount: Found token account $tokenAccountAddress " + "for owner=$ownerAddress, mint=$mint", + ); + return tokenAccountAddress; } catch (e) { Logging.instance.w("$runtimeType _findTokenAccount error: $e"); return null; @@ -771,13 +900,18 @@ class SolanaTokenWallet extends Wallet { ); try { - final ataAddress = _deriveAtaAddress( + final ataAddress = await _deriveAtaAddress( ownerAddress: recipientAddress, mint: mint, + rpcClient: rpcClient, ); - final ataBase58 = ataAddress.toBase58(); - Logging.instance.i("$runtimeType Derived ATA address: $ataBase58"); - return ataBase58; + if (ataAddress != null) { + Logging.instance.i("$runtimeType Derived ATA address: $ataAddress"); + return ataAddress; + } else { + Logging.instance.w("$runtimeType ATA derivation returned null"); + return null; + } } catch (derivationError) { Logging.instance.w( "$runtimeType Failed to derive ATA address: $derivationError", @@ -794,38 +928,128 @@ class SolanaTokenWallet extends Wallet { /// Derive the Associated Token Account (ATA) address for a given owner and mint. /// - /// Returns the derived ATA address as an Ed25519HDPublicKey. - /// This implementation uses the standard Solana ATA derivation formula: - /// ATA = findProgramAddress([b"account", owner, tokenProgram, mint], associatedTokenProgram) + /// The ATA is computed using: + /// PDA = findProgramAddress( + /// seeds = [b"account", owner_pubkey, token_program_id, mint_pubkey], + /// program_id = AssociatedTokenProgram + /// ) /// - /// NOTE: This is a simplified implementation. Proper implementation requires - /// the solana package to expose findProgramAddress utilities. - Ed25519HDPublicKey _deriveAtaAddress({ + /// Returns the derived ATA address as a base58 string, or null if derivation fails. + Future _deriveAtaAddress({ required String ownerAddress, required String mint, - }) { + required RpcClient rpcClient, + }) async { try { + // Parse public keys from base58. final ownerPubkey = Ed25519HDPublicKey.fromBase58(ownerAddress); final mintPubkey = Ed25519HDPublicKey.fromBase58(mint); - // For now, return a placeholder that the RPC lookup will either find - // or fail gracefully. In a production implementation, this should use - // proper Solana PDA derivation with findProgramAddress. - // - // The lookup in _findOrDeriveRecipientTokenAccount will try to find - // the actual token account first, and if not found, this derivation - // will be attempted (though it may not be correct without proper PDA logic). - - // Return the owner pubkey as a fallback - // The actual ATA will be looked up via RPC in most cases - return ownerPubkey; - } catch (e) { - Logging.instance.w("$runtimeType _deriveAtaAddress error: $e"); - rethrow; + // Detect which token program owns the mint by querying its owner directly. + // This is important because Token-2022 variants have different program IDs + // on different networks. + final tokenApi = SolanaTokenAPI(); + tokenApi.initializeRpcClient(rpcClient); + + String tokenProgramId; + try { + final mintInfo = await rpcClient.getAccountInfo( + mint, + encoding: Encoding.jsonParsed, + ); + if (mintInfo.value != null) { + tokenProgramId = mintInfo.value!.owner; + Logging.instance.i( + "$runtimeType _deriveAtaAddress: Detected token program owner=$tokenProgramId " + "for mint=$mint", + ); + } else { + // Fallback to SPL Token if we can't query. + tokenProgramId = 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA'; + Logging.instance.w( + "$runtimeType _deriveAtaAddress: Could not query mint, using default SPL Token program", + ); + } + } catch (e) { + // Fallback to SPL Token on error. + tokenProgramId = 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA'; + Logging.instance.w( + "$runtimeType _deriveAtaAddress: Error querying mint owner: $e, using SPL Token", + ); + } + + final tokenProgramPubkey = Ed25519HDPublicKey.fromBase58(tokenProgramId); + + // Associated Token Program ID (same for both SPL and Token-2022). + const associatedTokenProgramId = + 'ATokenGPvbdGVqstVQmcLsNZAqeEjlCoquUSjfJ5c'; + final associatedTokenProgramPubkey = Ed25519HDPublicKey.fromBase58( + associatedTokenProgramId, + ); + + // Build seeds for ATA PDA derivation. + // Seeds: ["account", owner, tokenProgram, mint] + final seeds = [ + 'account'.codeUnits, + ownerPubkey.toBase58().codeUnits, + tokenProgramPubkey.toBase58().codeUnits, + mintPubkey.toBase58().codeUnits, + ]; + + Logging.instance.i( + "$runtimeType _deriveAtaAddress: Building seeds for ATA derivation", + ); + + final ataAddress = await Ed25519HDPublicKey.findProgramAddress( + seeds: seeds, + programId: associatedTokenProgramPubkey, + ); + + final ataBase58 = ataAddress.toBase58(); + + Logging.instance.i( + "$runtimeType _deriveAtaAddress: Successfully derived ATA address " + "(owner=$ownerAddress, mint=$mint, program=$tokenProgramId) → " + "$ataBase58", + ); + + // Also verify the derived address actually exists on-chain + try { + final derivedAccountInfo = await rpcClient.getAccountInfo( + ataBase58, + encoding: Encoding.jsonParsed, + ); + if (derivedAccountInfo.value == null) { + Logging.instance.w( + "$runtimeType _deriveAtaAddress: WARNING - Derived ATA address " + "$ataBase58 does not exist on-chain. Recipient must initialize " + "their token account first.", + ); + } else { + Logging.instance.i( + "$runtimeType _deriveAtaAddress: Derived ATA exists on-chain - " + "owner=${derivedAccountInfo.value!.owner}, " + "lamports=${derivedAccountInfo.value!.lamports}", + ); + } + } catch (e) { + Logging.instance.w( + "$runtimeType _deriveAtaAddress: Could not verify derived ATA exists: $e", + ); + } + + return ataBase58; + } catch (e, stackTrace) { + Logging.instance.w( + "$runtimeType _deriveAtaAddress error: $e", + error: e, + stackTrace: stackTrace, + ); + return null; } } - /// Estimate the fee for an SPL token transfer transaction. + /// Estimate the fee for an token transfer transaction. /// /// Builds a token transfer message with the given parameters and uses /// the RPC `getFeeForMessage` call to get an accurate fee estimate. @@ -842,12 +1066,37 @@ class SolanaTokenWallet extends Wallet { // Get latest blockhash for message compilation. final latestBlockhash = await rpcClient.getLatestBlockhash(); - // Build the token transfer instruction. - final instruction = TokenInstruction.transfer( + final mintPubkey = Ed25519HDPublicKey.fromBase58(tokenMint); + + // Query the actual token program owner (important for Token-2022 variants). + String tokenProgramId; + try { + final mintInfo = await rpcClient.getAccountInfo( + tokenMint, + encoding: Encoding.jsonParsed, + ); + tokenProgramId = mintInfo.value?.owner ?? + 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA'; + } catch (e) { + tokenProgramId = 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA'; + } + + // Build the TransferChecked instruction. + // Determine which token program type to use based on the queried owner. + final TokenProgramType tokenProgram = + tokenProgramId != 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA' + && tokenProgramId.startsWith('Token') + ? TokenProgramType.token2022Program + : TokenProgramType.tokenProgram; + + final instruction = TokenInstruction.transferChecked( source: senderTokenAccountKey, destination: recipientTokenAccountKey, + mint: mintPubkey, owner: ownerPublicKey, + decimals: tokenDecimals, amount: amount, + tokenProgram: tokenProgram, ); // Compile the message with the blockhash. From a1e4e84a349d294f3711e2615e213ac5d0fa7b2c Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 21 Nov 2025 17:36:42 -0600 Subject: [PATCH 71/80] chore(spl): remove prints, log what's important, remove unnecessary docs --- lib/services/solana/solana_token_api.dart | 171 ++--------------- lib/wallets/wallet/impl/solana_wallet.dart | 11 +- .../impl/sub_wallets/solana_token_wallet.dart | 179 +----------------- 3 files changed, 17 insertions(+), 344 deletions(-) diff --git a/lib/services/solana/solana_token_api.dart b/lib/services/solana/solana_token_api.dart index ac1bdab6c..336562c97 100644 --- a/lib/services/solana/solana_token_api.dart +++ b/lib/services/solana/solana_token_api.dart @@ -21,9 +21,7 @@ class SolanaTokenApiException implements Exception { String toString() => 'SolanaTokenApiException: $message'; } -/// Response wrapper for Solana token API calls. -/// -/// Follows the pattern that the result is either value or exception +/// Result wrapper for Solana token API calls. class SolanaTokenApiResponse { final T? value; final Exception? exception; @@ -127,8 +125,6 @@ class SolanaTokenAPI { 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; } @@ -141,9 +137,6 @@ class SolanaTokenAPI { } } - /// Get token accounts owned by a wallet address for a specific mint. - /// - /// Returns a list of token account addresses owned by the wallet. Future>> getTokenAccountsByOwner( String ownerAddress, { String? mint, @@ -155,14 +148,12 @@ class SolanaTokenAPI { final result = await _rpcClient!.getTokenAccountsByOwner( ownerAddress, - // Create the appropriate filter: by mint if specified, or else all SPL tokens. mint != null ? TokenAccountsFilter.byMint(mint) : TokenAccountsFilter.byProgramId(splTokenProgramId), encoding: Encoding.jsonParsed, ); - // Extract token account addresses from the RPC response. final accountAddresses = result.value .map((account) => account.pubkey) .toList(); @@ -178,170 +169,73 @@ class SolanaTokenAPI { } } - /// 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). Future> getTokenAccountBalance( String tokenAccountAddress, ) async { try { _checkClient(); - // Query the token account with jsonParsed encoding to get token amount. final response = await _rpcClient!.getAccountInfo( tokenAccountAddress, encoding: Encoding.jsonParsed, ); if (response.value == null) { - // Token account doesn't exist. return SolanaTokenApiResponse(value: BigInt.zero); } final accountData = response.value!; - // Extract token amount from parsed data. try { - // Debug: Print the structure of accountData. - print( - '[SOLANA_TOKEN_API] accountData type: ${accountData.runtimeType}', - ); - print( - '[SOLANA_TOKEN_API] accountData.data type: ${accountData.data.runtimeType}', - ); - print('[SOLANA_TOKEN_API] accountData.data: ${accountData.data}'); - - // The solana package returns a ParsedAccountData which is a sealed class/union type. - // For SPL Token accounts, it contains SplTokenProgramAccountData. - final parsedData = accountData.data; if (parsedData is ParsedAccountData) { - print('[SOLANA_TOKEN_API] ParsedAccountData detected'); - try { final extractedBalance = parsedData.when( splToken: (spl) { - print('[SOLANA_TOKEN_API] Handling splToken variant'); - print('[SOLANA_TOKEN_API] spl type: ${spl.runtimeType}'); - return spl.when( account: (info, type, accountType) { - print('[SOLANA_TOKEN_API] Handling account variant'); - print('[SOLANA_TOKEN_API] info type: ${info.runtimeType}'); - print( - '[SOLANA_TOKEN_API] info.tokenAmount: ${info.tokenAmount}', - ); - try { final tokenAmount = info.tokenAmount; - print( - '[SOLANA_TOKEN_API] tokenAmount.amount: ${tokenAmount.amount}', - ); - print( - '[SOLANA_TOKEN_API] tokenAmount.decimals: ${tokenAmount.decimals}', - ); - - final balanceBigInt = BigInt.parse(tokenAmount.amount); - print( - '[SOLANA_TOKEN_API] Successfully extracted balance: $balanceBigInt', - ); - return balanceBigInt; + return BigInt.parse(tokenAmount.amount); } catch (e) { - print('[SOLANA_TOKEN_API] Error extracting balance: $e'); return null; } }, - mint: (info, type, accountType) { - print( - '[SOLANA_TOKEN_API] Got mint variant (not expected for token account balance)', - ); - return null; - }, - unknown: (type) { - print('[SOLANA_TOKEN_API] Got unknown account variant'); - return null; - }, - ); - }, - stake: (_) { - print( - '[SOLANA_TOKEN_API] Got stake account type (not expected)', + mint: (info, type, accountType) => null, + unknown: (type) => null, ); - return null; }, + stake: (_) => null, token2022: (token2022Data) { - print( - '[SOLANA_TOKEN_API] Handling token2022 account type', - ); - print('[SOLANA_TOKEN_API] token2022Data type: ${token2022Data.runtimeType}'); - return token2022Data.when( account: (info, type, accountType) { - print('[SOLANA_TOKEN_API] Handling token2022 account variant'); - print('[SOLANA_TOKEN_API] info type: ${info.runtimeType}'); - print( - '[SOLANA_TOKEN_API] info.tokenAmount: ${info.tokenAmount}', - ); - try { final tokenAmount = info.tokenAmount; - print( - '[SOLANA_TOKEN_API] tokenAmount.amount: ${tokenAmount.amount}', - ); - print( - '[SOLANA_TOKEN_API] tokenAmount.decimals: ${tokenAmount.decimals}', - ); - - final balanceBigInt = BigInt.parse(tokenAmount.amount); - print( - '[SOLANA_TOKEN_API] Successfully extracted token2022 balance: $balanceBigInt', - ); - return balanceBigInt; + return BigInt.parse(tokenAmount.amount); } catch (e) { - print('[SOLANA_TOKEN_API] Error extracting token2022 balance: $e'); return null; } }, - mint: (info, type, accountType) { - print( - '[SOLANA_TOKEN_API] Got token2022 mint variant (not expected for token account balance)', - ); - return null; - }, - unknown: (type) { - print('[SOLANA_TOKEN_API] Got unknown token2022 account variant'); - return null; - }, + mint: (info, type, accountType) => null, + unknown: (type) => null, ); }, - unsupported: (_) { - print('[SOLANA_TOKEN_API] Got unsupported account type'); - return null; - }, + unsupported: (_) => null, ); if (extractedBalance != null && extractedBalance is BigInt) { - print('[SOLANA_TOKEN_API] Extracted balance: $extractedBalance'); return SolanaTokenApiResponse( value: extractedBalance as BigInt, ); } } catch (e) { - print('[SOLANA_TOKEN_API] Error using when() method: $e'); - print('[SOLANA_TOKEN_API] Stack trace: ${StackTrace.current}'); + // Ignore parsing errors. } } - // If we can't extract from the Dart object, return zero. - print('[SOLANA_TOKEN_API] Returning zero balance'); return SolanaTokenApiResponse(value: BigInt.zero); } catch (e) { - // If parsing fails, return zero balance. - print('[SOLANA_TOKEN_API] Exception during parsing: $e'); return SolanaTokenApiResponse(value: BigInt.zero); } } on Exception catch (e) { @@ -354,23 +248,11 @@ class SolanaTokenAPI { } } - /// 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 placeholders. - /// - /// TODO: Implement full RPC call when API is ready. + // TODO: Implement full RPC call when API is ready. Future> 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( value: BigInt.parse('1000000000000000000'), ); @@ -384,16 +266,7 @@ class SolanaTokenAPI { } } - /// 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 placeholders. - /// - /// TODO: Implement full RPC call when API is ready. + // TODO: Implement full RPC call when API is ready. Future> getTokenAccountInfo( String tokenAccountAddress, ) async { @@ -423,13 +296,6 @@ class SolanaTokenAPI { } } - /// 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. // @@ -437,9 +303,6 @@ class SolanaTokenAPI { return ''; } - /// Check if a wallet owns a token (has a token account for the given mint). - /// - /// Returns true if the wallet has a token account for this mint, false otherwise. Future> ownsToken( String ownerAddress, String mint, @@ -468,16 +331,6 @@ class SolanaTokenAPI { } } - /// Fetch SPL token metadata from Solana metadata program. - /// - /// The Solana Token Metadata program (metaqbxxUerdq28cj1RbAqWwTRiWLs6nshmbbuP3xqb) - /// stores token metadata at a PDA derived from the mint address. - /// - /// Returns: Map with name, symbol, decimals, and optional logo URI - /// Returns null if metadata cannot be found (user can enter custom details), - /// - /// Note: Full PDA derivation is not yet implemented in the solana package. - /// Currently returns null to allow users to manually enter token details. Future?>> fetchTokenMetadataByMint( String mintAddress, diff --git a/lib/wallets/wallet/impl/solana_wallet.dart b/lib/wallets/wallet/impl/solana_wallet.dart index 4e0882a3e..01969e136 100644 --- a/lib/wallets/wallet/impl/solana_wallet.dart +++ b/lib/wallets/wallet/impl/solana_wallet.dart @@ -37,18 +37,12 @@ class SolanaWallet extends Bip39Wallet { NodeModel? _solNode; - RpcClient? _rpcClient; // The Solana RpcClient. + RpcClient? _rpcClient; - /// Get the RPC client for this wallet. - /// - /// This is used by services like SolanaTokenAPI that need to make RPC calls. RpcClient? getRpcClient() { return _rpcClient; } - /// Get the keypair for this wallet. - /// - /// Used internally and by token wallets for signing transactions. Future getKeyPair() async { return _getKeyPair(); } @@ -648,11 +642,9 @@ class SolanaWallet extends Bip39Wallet { @override Future updateUTXOs() async { - // No UTXOs in Solana return false; } - /// Update the list of custom Solana token mint addresses for this wallet. Future updateSolanaTokens(Set mintAddresses) async { await info.updateSolanaCustomTokenMintAddresses( newMintAddresses: mintAddresses, @@ -667,7 +659,6 @@ class SolanaWallet extends Bip39Wallet { ); } - /// Make sure the Solana RpcClient uses Tor if it's enabled. void checkClient() { final node = getCurrentNode(); diff --git a/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart b/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart index d7dfcd966..6c9d913c2 100644 --- a/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart +++ b/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart @@ -27,21 +27,13 @@ import '../../../models/tx_data.dart'; import '../../wallet.dart'; import '../solana_wallet.dart'; -/// Solana Token Wallet for SPL token transfers. -/// -/// Implements send functionality for Solana SPL tokens (like USDC, USDT, etc.) -/// by delegating RPC calls and key management to the parent SolanaWallet. class SolanaTokenWallet extends Wallet { @override int get isarTransactionVersion => 2; - /// Create a new Solana Token Wallet. - /// - /// Requires a parent SolanaWallet to provide RPC client and key management. SolanaTokenWallet(this.parentSolanaWallet, this.splToken) : super(parentSolanaWallet.cryptoCurrency); - /// Parent Solana wallet (provides RPC client and keypair access). final SolanaWallet parentSolanaWallet; final SplToken splToken; @@ -51,19 +43,12 @@ class SolanaTokenWallet extends Wallet { String get tokenSymbol => splToken.symbol; int get tokenDecimals => splToken.decimals; - /// Override walletId to delegate to parent wallet @override String get walletId => parentSolanaWallet.walletId; - /// Override mainDB to delegate to parent wallet - /// (SolanaTokenWallet shares the same database as its parent) @override MainDB get mainDB => parentSolanaWallet.mainDB; - // ========================================================================= - // Abstract method implementations - // ========================================================================= - @override FilterOperation? get changeAddressFilterOperation => null; @@ -86,7 +71,6 @@ class SolanaTokenWallet extends Wallet { @override Future prepareSend({required TxData txData}) async { try { - // Input validation. if (txData.recipients == null || txData.recipients!.isEmpty) { throw ArgumentError("At least one recipient is required"); } @@ -106,14 +90,12 @@ class SolanaTokenWallet extends Wallet { throw ArgumentError("Recipient address cannot be empty"); } - // Validate recipient is a valid base58 address. try { Ed25519HDPublicKey.fromBase58(recipientAddress); } catch (e) { throw ArgumentError("Invalid recipient address: $recipientAddress"); } - // Get wallet state. final rpcClient = parentSolanaWallet.getRpcClient(); if (rpcClient == null) { throw Exception("RPC client not initialized"); @@ -122,7 +104,6 @@ class SolanaTokenWallet extends Wallet { final keyPair = await parentSolanaWallet.getKeyPair(); final walletAddress = keyPair.address; - // Get sender's token acct. final senderTokenAccount = await _findTokenAccount( ownerAddress: walletAddress, mint: tokenMint, @@ -136,7 +117,6 @@ class SolanaTokenWallet extends Wallet { ); } - // Validate sender token account exists and has proper data. try { final accountInfo = await rpcClient.getAccountInfo( senderTokenAccount, @@ -151,10 +131,8 @@ class SolanaTokenWallet extends Wallet { throw Exception("Failed to validate sender token account: $e"); } - // Get latest block hash (used internally by RPC client). await rpcClient.getLatestBlockhash(); - // Get recipient's token account (or derive ATA if it doesn't exist). final recipientTokenAccount = await _findOrDeriveRecipientTokenAccount( recipientAddress: recipientAddress, mint: tokenMint, @@ -194,7 +172,7 @@ class SolanaTokenWallet extends Wallet { } catch (e) { if (e.toString().contains("does not exist") || e.toString().contains("not owned by")) { - rethrow; // Re-throw our validation errors. + rethrow; } throw Exception( "Failed to validate recipient token account: $e. " @@ -202,7 +180,6 @@ class SolanaTokenWallet extends Wallet { ); } - // Build SPL token tx instruction. final senderTokenAccountKey = Ed25519HDPublicKey.fromBase58( senderTokenAccount, ); @@ -211,7 +188,6 @@ class SolanaTokenWallet extends Wallet { ); final mintPubkey = Ed25519HDPublicKey.fromBase58(tokenMint); - // Query the actual token program owner (important for Token-2022 variants). String tokenProgramId; try { final mintInfo = await rpcClient.getAccountInfo( @@ -238,8 +214,6 @@ class SolanaTokenWallet extends Wallet { ); } - // Build the transfer instruction (will be rebuilt in confirmSend with updated blockhash). - // Determine which token program type to use based on the queried owner. final TokenProgramType tokenProgram = tokenProgramId != 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA' && tokenProgramId.startsWith('Token') @@ -257,7 +231,6 @@ class SolanaTokenWallet extends Wallet { tokenProgram: tokenProgram, ); - // Estimate fee. final feeEstimate = await _getEstimatedTokenTransferFee( senderTokenAccountKey: senderTokenAccountKey, @@ -268,11 +241,10 @@ class SolanaTokenWallet extends Wallet { ) ?? 5000; - // Return prepared TxData. return txData.copyWith( fee: Amount( rawValue: BigInt.from(feeEstimate), - fractionDigits: 9, // Solana uses 9 decimal places for lamports. + fractionDigits: 9, ), solanaRecipientTokenAccount: recipientTokenAccount, ); @@ -330,12 +302,7 @@ class SolanaTokenWallet extends Wallet { ); } - // Log the token account for debugging. - Logging.instance.i( - "$runtimeType confirmSend - using recipient token account: $recipientTokenAccount", - ); - - // 5. Build SPL token tx instruction. + // Build SPL token tx instruction. final senderTokenAccountKey = Ed25519HDPublicKey.fromBase58( senderTokenAccount, ); @@ -388,10 +355,6 @@ class SolanaTokenWallet extends Wallet { tokenProgram: tokenProgram, ); - Logging.instance.i( - "$runtimeType confirmSend: Built TransferChecked instruction for token program $tokenProgramId", - ); - // Create message. final message = Message(instructions: [instruction]); @@ -678,26 +641,14 @@ class SolanaTokenWallet extends Wallet { @override Future updateBalance() async { try { - Logging.instance.i( - "$runtimeType updateBalance: Starting balance update for tokenMint=$tokenMint", - ); - final rpcClient = parentSolanaWallet.getRpcClient(); if (rpcClient == null) { - Logging.instance.w( - "$runtimeType updateBalance: RPC client not initialized", - ); return; } final keyPair = await parentSolanaWallet.getKeyPair(); final walletAddress = keyPair.address; - Logging.instance.i( - "$runtimeType updateBalance: Wallet address = $walletAddress", - ); - - // Get sender's token account. final senderTokenAccount = await _findTokenAccount( ownerAddress: walletAddress, mint: tokenMint, @@ -705,17 +656,9 @@ class SolanaTokenWallet extends Wallet { ); if (senderTokenAccount == null) { - Logging.instance.w( - "$runtimeType updateBalance: No token account found for mint $tokenMint", - ); return; } - Logging.instance.i( - "$runtimeType updateBalance: Found token account = $senderTokenAccount", - ); - - // Fetch the token balance. final tokenApi = SolanaTokenAPI(); tokenApi.initializeRpcClient(rpcClient); @@ -731,26 +674,12 @@ class SolanaTokenWallet extends Wallet { } if (balanceResponse.value != null) { - // Log the updated balance. - Logging.instance.i( - "$runtimeType updateBalance: New balance = ${balanceResponse.value} (${balanceResponse.value! / BigInt.from(10).pow(tokenDecimals)} ${tokenSymbol})", - ); - - // Persist balance to WalletSolanaTokenInfo in Isar database. - Logging.instance.i( - "$runtimeType updateBalance: Looking up WalletSolanaTokenInfo for walletId=$walletId, tokenMint=$tokenMint", - ); - final info = await mainDB.isar.walletSolanaTokenInfo .where() .walletIdTokenAddressEqualTo(walletId, tokenMint) .findFirst(); if (info != null) { - Logging.instance.i( - "$runtimeType updateBalance: Found WalletSolanaTokenInfo with ID=${info.id}, updating cached balance", - ); - final balanceAmount = Amount( rawValue: balanceResponse.value!, fractionDigits: tokenDecimals, @@ -794,11 +723,6 @@ class SolanaTokenWallet extends Wallet { @override Future refresh() async { - Logging.instance.i( - "$runtimeType refresh: Starting refresh for tokenMint=$tokenMint", - ); - // Refresh both the parent wallet and token balance. - // This ensures the cached token balance in the database is updated. await parentSolanaWallet.refresh(); await updateBalance(); await updateTransactions(); @@ -806,36 +730,22 @@ class SolanaTokenWallet extends Wallet { @override Future estimateFeeFor(Amount amount, BigInt feeRate) async { - // Delegate to parent SolanaWallet for fee estimation. - // For token transfers, the fee is the same as a regular SOL transfer. return parentSolanaWallet.estimateFeeFor(amount, feeRate); } @override Future get fees async { - // Delegate to parent SolanaWallet for fee information. - // For token transfers, the fees are the same as regular SOL transfers. return parentSolanaWallet.fees; } @override Future pingCheck() async { - // Delegate to parent SolanaWallet for RPC health check. return parentSolanaWallet.pingCheck(); } @override - Future checkSaveInitialReceivingAddress() async { - // Token accounts are derived, not managed separately. - } + Future checkSaveInitialReceivingAddress() async {} - // ========================================================================= - // Helper methods - // ========================================================================= - - /// Find a token account for the given owner and mint. - /// - /// Returns the token account address if found, otherwise null. Future _findTokenAccount({ required String ownerAddress, required String mint, @@ -868,12 +778,6 @@ class SolanaTokenWallet extends Wallet { } } - /// Find or derive the recipient's token account for a given mint. - /// - /// This method first attempts to find an existing token account owned by the recipient. - /// If not found, it attempts to derive the ATA (Associated Token Account) address. - /// - /// Returns the token account address if found or derived, otherwise null. Future _findOrDeriveRecipientTokenAccount({ required String recipientAddress, required String mint, @@ -926,28 +830,15 @@ class SolanaTokenWallet extends Wallet { } } - /// Derive the Associated Token Account (ATA) address for a given owner and mint. - /// - /// The ATA is computed using: - /// PDA = findProgramAddress( - /// seeds = [b"account", owner_pubkey, token_program_id, mint_pubkey], - /// program_id = AssociatedTokenProgram - /// ) - /// - /// Returns the derived ATA address as a base58 string, or null if derivation fails. Future _deriveAtaAddress({ required String ownerAddress, required String mint, required RpcClient rpcClient, }) async { try { - // Parse public keys from base58. final ownerPubkey = Ed25519HDPublicKey.fromBase58(ownerAddress); final mintPubkey = Ed25519HDPublicKey.fromBase58(mint); - // Detect which token program owns the mint by querying its owner directly. - // This is important because Token-2022 variants have different program IDs - // on different networks. final tokenApi = SolanaTokenAPI(); tokenApi.initializeRpcClient(rpcClient); @@ -959,36 +850,21 @@ class SolanaTokenWallet extends Wallet { ); if (mintInfo.value != null) { tokenProgramId = mintInfo.value!.owner; - Logging.instance.i( - "$runtimeType _deriveAtaAddress: Detected token program owner=$tokenProgramId " - "for mint=$mint", - ); } else { - // Fallback to SPL Token if we can't query. tokenProgramId = 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA'; - Logging.instance.w( - "$runtimeType _deriveAtaAddress: Could not query mint, using default SPL Token program", - ); } } catch (e) { - // Fallback to SPL Token on error. tokenProgramId = 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA'; - Logging.instance.w( - "$runtimeType _deriveAtaAddress: Error querying mint owner: $e, using SPL Token", - ); } final tokenProgramPubkey = Ed25519HDPublicKey.fromBase58(tokenProgramId); - // Associated Token Program ID (same for both SPL and Token-2022). const associatedTokenProgramId = 'ATokenGPvbdGVqstVQmcLsNZAqeEjlCoquUSjfJ5c'; final associatedTokenProgramPubkey = Ed25519HDPublicKey.fromBase58( associatedTokenProgramId, ); - // Build seeds for ATA PDA derivation. - // Seeds: ["account", owner, tokenProgram, mint] final seeds = [ 'account'.codeUnits, ownerPubkey.toBase58().codeUnits, @@ -996,10 +872,6 @@ class SolanaTokenWallet extends Wallet { mintPubkey.toBase58().codeUnits, ]; - Logging.instance.i( - "$runtimeType _deriveAtaAddress: Building seeds for ATA derivation", - ); - final ataAddress = await Ed25519HDPublicKey.findProgramAddress( seeds: seeds, programId: associatedTokenProgramPubkey, @@ -1007,37 +879,6 @@ class SolanaTokenWallet extends Wallet { final ataBase58 = ataAddress.toBase58(); - Logging.instance.i( - "$runtimeType _deriveAtaAddress: Successfully derived ATA address " - "(owner=$ownerAddress, mint=$mint, program=$tokenProgramId) → " - "$ataBase58", - ); - - // Also verify the derived address actually exists on-chain - try { - final derivedAccountInfo = await rpcClient.getAccountInfo( - ataBase58, - encoding: Encoding.jsonParsed, - ); - if (derivedAccountInfo.value == null) { - Logging.instance.w( - "$runtimeType _deriveAtaAddress: WARNING - Derived ATA address " - "$ataBase58 does not exist on-chain. Recipient must initialize " - "their token account first.", - ); - } else { - Logging.instance.i( - "$runtimeType _deriveAtaAddress: Derived ATA exists on-chain - " - "owner=${derivedAccountInfo.value!.owner}, " - "lamports=${derivedAccountInfo.value!.lamports}", - ); - } - } catch (e) { - Logging.instance.w( - "$runtimeType _deriveAtaAddress: Could not verify derived ATA exists: $e", - ); - } - return ataBase58; } catch (e, stackTrace) { Logging.instance.w( @@ -1049,12 +890,6 @@ class SolanaTokenWallet extends Wallet { } } - /// Estimate the fee for an token transfer transaction. - /// - /// Builds a token transfer message with the given parameters and uses - /// the RPC `getFeeForMessage` call to get an accurate fee estimate. - /// - /// Returns the estimated fee in lamports, or null if estimation fails. Future _getEstimatedTokenTransferFee({ required Ed25519HDPublicKey senderTokenAccountKey, required Ed25519HDPublicKey recipientTokenAccountKey, @@ -1128,12 +963,6 @@ class SolanaTokenWallet extends Wallet { } } - /// Wait for transaction confirmation on-chain. - /// - /// Polls the RPC node until the transaction reaches the desired commitment - /// level or until timeout is reached. - /// - /// Returns true if confirmed, false if timeout or error occurred. Future _waitForConfirmation({ required String signature, required int maxWaitSeconds, From 658ff80552222d268f60b35b39d8050bf1e363d9 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 24 Nov 2025 20:11:52 -0600 Subject: [PATCH 72/80] chore: cleanup --- lib/services/solana/solana_token_api.dart | 53 ++++------------------- 1 file changed, 8 insertions(+), 45 deletions(-) diff --git a/lib/services/solana/solana_token_api.dart b/lib/services/solana/solana_token_api.dart index 336562c97..3798e45c2 100644 --- a/lib/services/solana/solana_token_api.dart +++ b/lib/services/solana/solana_token_api.dart @@ -408,58 +408,21 @@ class SolanaTokenAPI { final owner = response.value!.owner; - // Check which program owns this mint. - // SPL Token: TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA - // Token-2022: TokenzQdBNbLvnVCrqtsvQQrXTVkDkAydS7d5xgqfnb + // Rough check which program owns this mint. + // + // For now all we need to know ius if it's SPL or newer. + // TODO [prio=low]: Fix via program metadata parsing or similar. if (owner == 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA') { return 'spl'; + } else { + if (owner.startsWith('Token')) { + return 'token2022'; + } } - if (owner.startsWith('Token') && owner != 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA') { - print('[SOLANA_TOKEN_API] Detected Token-2022 variant: $owner'); - return 'token2022'; - } - - return null; - } catch (e) { - print('[SOLANA_TOKEN_API] Error detecting token program: $e'); - return null; - } - } - - /// Derive the metadata PDA for a given mint address. - /// - /// This is a temporary implementation that queries known metadata endpoints. - /// In production, this should use solana package's findProgramAddress utilities. - /// - /// Returns: metadata PDA address or null if derivation fails - Future _deriveMetadataPda(String mintAddress) async { - try { - // Validate the mint address first - if (!isValidSolanaMintAddress(mintAddress)) { - return null; - } - - // TODO: Implement proper PDA derivation using solana package's findProgramAddress - // This is a placeholder that would need to be updated when solana package - // exposes the necessary utilities - // - // For now, we return null to trigger fallback behavior - // In a real implementation, you would derive the PDA like: - // final seeds = [ - // 'metadata'.codeUnits, - // metadataProgram.toBytes(), - // mint.toBytes(), - // ]; - // final (pda, _) = Ed25519HDPublicKey.findProgramAddress( - // seeds, - // metadataProgram, - // ); - // return pda.toBase58(); return null; } catch (e) { return null; } } - } From 44fed9a2ac1d6d34a4abec4ef1a2f414b00b5c0b Mon Sep 17 00:00:00 2001 From: julian Date: Tue, 25 Nov 2025 10:38:54 -0600 Subject: [PATCH 73/80] dart format and a couple tweaks/fixes --- lib/db/isar/main_db.dart | 7 +-- .../isar/models/ethereum/eth_contract.dart | 28 ++++----- lib/models/isar/models/solana/spl_token.dart | 22 ++++--- .../add_custom_solana_token_view.dart | 59 ++++++++++--------- 4 files changed, 60 insertions(+), 56 deletions(-) diff --git a/lib/db/isar/main_db.dart b/lib/db/isar/main_db.dart index 424572990..a59364705 100644 --- a/lib/db/isar/main_db.dart +++ b/lib/db/isar/main_db.dart @@ -641,8 +641,7 @@ class MainDB { return await isar.splTokens.put(token); }); - Future putSplTokens(List tokens) => - isar.writeTxn(() async { - await isar.splTokens.putAll(tokens); - }); + Future putSplTokens(List tokens) => isar.writeTxn(() async { + await isar.splTokens.putAll(tokens); + }); } diff --git a/lib/models/isar/models/ethereum/eth_contract.dart b/lib/models/isar/models/ethereum/eth_contract.dart index adaec3122..ad98f2988 100644 --- a/lib/models/isar/models/ethereum/eth_contract.dart +++ b/lib/models/isar/models/ethereum/eth_contract.dart @@ -9,6 +9,7 @@ */ import 'package:isar_community/isar.dart'; + import '../contract.dart'; part 'eth_contract.g.dart'; @@ -26,13 +27,17 @@ class EthContract extends Contract { Id id = Isar.autoIncrement; + @override @Index(unique: true, replace: true) late final String address; + @override late final String name; + @override late final String symbol; + @override late final int decimals; late final String? abi; @@ -50,21 +55,16 @@ class EthContract extends Contract { List? walletIds, String? abi, String? otherData, - }) => - EthContract( - address: address ?? this.address, - name: name ?? this.name, - symbol: symbol ?? this.symbol, - decimals: decimals ?? this.decimals, - type: type ?? this.type, - abi: abi ?? this.abi, - )..id = id ?? this.id; + }) => EthContract( + address: address ?? this.address, + name: name ?? this.name, + symbol: symbol ?? this.symbol, + decimals: decimals ?? this.decimals, + type: type ?? this.type, + abi: abi ?? this.abi, + )..id = id ?? this.id; } // Used in Isar db and stored there as int indexes so adding/removing values // in this definition should be done extremely carefully in production -enum EthContractType { - unknown, - erc20, - erc721; -} +enum EthContractType { unknown, erc20, erc721 } diff --git a/lib/models/isar/models/solana/spl_token.dart b/lib/models/isar/models/solana/spl_token.dart index 736ee3155..218001d76 100644 --- a/lib/models/isar/models/solana/spl_token.dart +++ b/lib/models/isar/models/solana/spl_token.dart @@ -8,6 +8,7 @@ */ import 'package:isar_community/isar.dart'; + import '../contract.dart'; part 'spl_token.g.dart'; @@ -25,13 +26,17 @@ class SplToken extends Contract { Id id = Isar.autoIncrement; + @override @Index(unique: true, replace: true) late final String address; // Mint address. + @override late final String name; + @override late final String symbol; + @override late final int decimals; late final String? logoUri; @@ -46,13 +51,12 @@ class SplToken extends Contract { int? decimals, String? logoUri, String? metadataAddress, - }) => - SplToken( - address: address ?? this.address, - name: name ?? this.name, - symbol: symbol ?? this.symbol, - decimals: decimals ?? this.decimals, - logoUri: logoUri ?? this.logoUri, - metadataAddress: metadataAddress ?? this.metadataAddress, - )..id = id ?? this.id; + }) => SplToken( + address: address ?? this.address, + name: name ?? this.name, + symbol: symbol ?? this.symbol, + decimals: decimals ?? this.decimals, + logoUri: logoUri ?? this.logoUri, + metadataAddress: metadataAddress ?? this.metadataAddress, + )..id = id ?? this.id; } 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 index 9c3b2f930..c99816d59 100644 --- 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 @@ -30,10 +30,7 @@ import '../../../widgets/desktop/secondary_button.dart'; import '../../../widgets/stack_dialog.dart'; class AddCustomSolanaTokenView extends ConsumerStatefulWidget { - const AddCustomSolanaTokenView({ - super.key, - this.walletId, - }); + const AddCustomSolanaTokenView({super.key, this.walletId}); static const routeName = "/addCustomSolanaToken"; @@ -71,7 +68,7 @@ class _AddCustomSolanaTokenViewState // Check if token is already in the wallet. if (widget.walletId != null) { - final walletInfo = ref.watch(pWalletInfo(widget.walletId!)); + final walletInfo = ref.read(pWalletInfo(widget.walletId!)); final allTokenMints = { ...walletInfo.solanaTokenMintAddresses, ...walletInfo.solanaCustomTokenMintAddresses, @@ -119,7 +116,8 @@ class _AddCustomSolanaTokenViewState Expanded( child: PrimaryButton( label: "OK", - onPressed: () => Navigator.of(dialogContext).pop(), + onPressed: () => + Navigator.of(dialogContext).pop(), ), ), ], @@ -149,10 +147,7 @@ class _AddCustomSolanaTokenViewState builder: (child) => DesktopDialog( maxWidth: 500, maxHeight: double.infinity, - child: Padding( - padding: const EdgeInsets.all(32), - child: child, - ), + child: Padding(padding: const EdgeInsets.all(32), child: child), ), child: ConditionalParent( condition: !Util.isDesktop, @@ -166,7 +161,8 @@ class _AddCustomSolanaTokenViewState ), const SizedBox(height: 8), Text( - "Please enter a valid Solana SPL token mint address (base58 encoded, ~44 characters).", + "Please enter a valid Solana SPL token mint address " + "(base58 encoded, ~44 characters).", style: STextStyles.smallMed14(context), ), const SizedBox(height: 20), @@ -192,13 +188,19 @@ class _AddCustomSolanaTokenViewState } // Fetch token metadata. - debugPrint('[ADD_CUSTOM_SOLANA_TOKEN] Fetching metadata for mint: ${mintController.text.trim()}'); - final response = - await tokenApi.fetchTokenMetadataByMint(mintController.text.trim()); + 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}'); + debugPrint( + '[ADD_CUSTOM_SOLANA_TOKEN] Metadata response: ${response.value}', + ); if (response.value != null && response.value!.isNotEmpty) { final metadata = response.value!; @@ -206,7 +208,7 @@ class _AddCustomSolanaTokenViewState address: mintController.text.trim(), name: metadata['name'] as String? ?? 'Unknown Token', symbol: metadata['symbol'] as String? ?? '???', - decimals: metadata['decimals'] as int? ?? 6, + decimals: int.tryParse(metadata['decimals']?.toString() ?? "") ?? 6, logoUri: metadata['logoUri'] as String?, ); @@ -222,12 +224,15 @@ class _AddCustomSolanaTokenViewState 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'); + 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. + // Enable fields for manual entry and allow user to create token with + // custom values. setState(() { enableSubFields = true; currentToken = SplToken( @@ -241,7 +246,8 @@ class _AddCustomSolanaTokenViewState addTokenButtonEnabled = true; }); - // Show dialog for manual entry & alert the user they need to enter details manually. + // Show dialog for manual entry & alert the user they need to enter + // details manually. if (mounted) { unawaited( showDialog( @@ -251,10 +257,7 @@ class _AddCustomSolanaTokenViewState builder: (child) => DesktopDialog( maxWidth: 500, maxHeight: double.infinity, - child: Padding( - padding: const EdgeInsets.all(32), - child: child, - ), + child: Padding(padding: const EdgeInsets.all(32), child: child), ), child: ConditionalParent( condition: !Util.isDesktop, @@ -268,7 +271,8 @@ class _AddCustomSolanaTokenViewState ), const SizedBox(height: 8), Text( - "Could not fetch token metadata. Please enter the token details manually below.", + "Could not fetch token metadata. Please enter the token" + " details manually below.", style: STextStyles.smallMed14(context), ), const SizedBox(height: 20), @@ -371,10 +375,7 @@ class _AddCustomSolanaTokenViewState ), ), SizedBox(height: isDesktop ? 16 : 8), - PrimaryButton( - label: "Search", - onPressed: _searchTokenMetadata, - ), + PrimaryButton(label: "Search", onPressed: _searchTokenMetadata), SizedBox(height: isDesktop ? 16 : 8), TextField( enabled: enableSubFields, @@ -497,7 +498,7 @@ class _AddCustomSolanaTokenViewState : currentToken!.symbol, decimals: decimalsController.text.isNotEmpty ? int.tryParse(decimalsController.text) ?? - currentToken!.decimals + currentToken!.decimals : currentToken!.decimals, ); Navigator.of(context).pop(finalToken); From 6e345170bbb81a19d6de95b7d011c677b43d403e Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 25 Nov 2025 12:17:02 -0600 Subject: [PATCH 74/80] feat(spl): add sol tokens to add wallet list --- .../sub_classes/sol_token_entity.dart | 31 +++ .../add_wallet_view/add_wallet_view.dart | 73 ++++- .../sub_widgets/coin_select_item.dart | 42 ++- .../sub_widgets/next_button.dart | 7 + .../select_wallet_for_sol_token_view.dart | 252 ++++++++++++++++++ lib/route_generator.dart | 12 + 6 files changed, 414 insertions(+), 3 deletions(-) create mode 100644 lib/models/add_wallet_list_entity/sub_classes/sol_token_entity.dart create mode 100644 lib/pages/add_wallet_views/select_wallet_for_sol_token_view.dart 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 000000000..f7a35ca7b --- /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 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 4d289a466..56012c8dc 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 { final List coinEntities = []; final List coinTestnetEntities = []; final List tokenEntities = []; + final List solTokenEntities = []; final bool isDesktop = Util.isDesktop; @@ -84,6 +89,8 @@ class _AddWalletViewState extends ConsumerState { 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 { } } + Future _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 { tokenEntities.addAll(contracts.map((e) => EthTokenEntity(e))); } + if (AppConfig.coins.whereType().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 { ), 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 { 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 { ), 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 72adf390b..4f860c1fa 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 { }); } }); + } 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 { 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 4447cc7a9..f88b49178 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 000000000..d5c012b53 --- /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 createState() => + _SelectWalletForSolTokenViewState(); +} + +class _SelectWalletForSolTokenViewState + extends ConsumerState { + 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 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()!.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()! + .popupBG + : _selectedWalletId == solWalletIds[index] + ? Theme.of(context) + .extension()! + .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 1a91b27fb..35bf76bbb 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, From 71fa54d91a0a938a05269612927deb92ab5a8425 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 25 Nov 2025 12:59:54 -0600 Subject: [PATCH 75/80] refactor(spl): reduce default spl token list the sol token list could also be trimmed down but I am not comfortable with that yet or without changing the initialization process there bc as it's async, we may need to wait --- .../add_wallet_view/add_wallet_view.dart | 14 ++--------- .../sub_widgets/sol_tokens_list.dart | 24 ++----------------- 2 files changed, 4 insertions(+), 34 deletions(-) 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 56012c8dc..c1db3b5eb 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 @@ -29,7 +29,6 @@ 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'; @@ -193,17 +192,8 @@ class _AddWalletViewState extends ConsumerState { } if (AppConfig.coins.whereType().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)); - } - } + final tokens = MainDB.instance.getSplTokens().findAllSync(); + solTokenEntities.addAll(tokens.map((e) => SolTokenEntity(e))); } WidgetsBinding.instance.addPostFrameCallback((_) { diff --git a/lib/pages/token_view/sub_widgets/sol_tokens_list.dart b/lib/pages/token_view/sub_widgets/sol_tokens_list.dart index d063fc130..e2502b791 100644 --- a/lib/pages/token_view/sub_widgets/sol_tokens_list.dart +++ b/lib/pages/token_view/sub_widgets/sol_tokens_list.dart @@ -13,7 +13,6 @@ import 'package:isar_community/isar.dart'; import '../../../models/isar/models/solana/spl_token.dart'; import '../../../providers/db/main_db_provider.dart'; -import '../../../utilities/default_spl_tokens.dart'; import '../../../utilities/util.dart'; import 'sol_token_select_item.dart'; @@ -61,29 +60,10 @@ class SolanaTokensList extends StatelessWidget { return Consumer( builder: (_, ref, __) { - // Get all available SPL tokens: combine defaults with custom tokens from database. + // Get all available SOL tokens. final db = ref.watch(mainDBProvider); - // Query all SplTokens from the database (includes both defaults and custom tokens). - final allDatabaseTokens = db.getSplTokens().findAllSync(); - - // Combined token lists: prioritize database tokens, fall back to defaults. - final allTokens = []; - final seenAddresses = {}; - - // Add all database tokens. - for (final token in allDatabaseTokens) { - allTokens.add(token); - seenAddresses.add(token.address); - } - - // Add default tokens that aren't already in the database. - for (final defaultToken in DefaultSplTokens.list) { - if (!seenAddresses.contains(defaultToken.address)) { - allTokens.add(defaultToken); - seenAddresses.add(defaultToken.address); - } - } + final allTokens = db.getSplTokens().findAllSync(); final tokens = _filter(searchTerm, allTokens); From 7b0a23e5b67a15c33dd1740bf20a9ecb2b2085ef Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 25 Nov 2025 13:05:10 -0600 Subject: [PATCH 76/80] chore(spl): "SPL" -> "SOL" (we use both SPL and Token-2022 SOL tokens) --- lib/db/isar/main_db.dart | 2 +- lib/models/isar/models/blockchain_data/transaction.dart | 2 +- .../add_token_view/add_custom_solana_token_view.dart | 4 ++-- .../wallet_view/sub_widgets/desktop_send_fee_form.dart | 2 +- lib/services/price.dart | 2 +- lib/utilities/amount/amount_unit.dart | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/db/isar/main_db.dart b/lib/db/isar/main_db.dart index a59364705..a3a18a7f1 100644 --- a/lib/db/isar/main_db.dart +++ b/lib/db/isar/main_db.dart @@ -626,7 +626,7 @@ class MainDB { // ========== Solana ========================================================= - // Solana (SPL) tokens. + // Solana tokens. QueryBuilder getSplTokens() => isar.splTokens.where(); diff --git a/lib/models/isar/models/blockchain_data/transaction.dart b/lib/models/isar/models/blockchain_data/transaction.dart index 07b4b912f..a4c808bfb 100644 --- a/lib/models/isar/models/blockchain_data/transaction.dart +++ b/lib/models/isar/models/blockchain_data/transaction.dart @@ -261,5 +261,5 @@ enum TransactionSubType { sparkSpend, // firo specific ordinal, mweb, - splToken, // Solana token (SPL). + splToken, // Solana token. } 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 index c99816d59..ab232287f 100644 --- 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 @@ -336,7 +336,7 @@ class _AddCustomSolanaTokenViewState Padding( padding: const EdgeInsets.only(left: 32), child: Text( - "Add custom SPL token", + "Add custom SOL token", style: STextStyles.desktopH3(context), ), ), @@ -360,7 +360,7 @@ class _AddCustomSolanaTokenViewState children: [ if (!isDesktop) Text( - "Add custom SPL token", + "Add custom SOL token", style: STextStyles.pageTitleH1(context), ), if (!isDesktop) const SizedBox(height: 16), diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart index f67750406..0b9377599 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart @@ -212,7 +212,7 @@ class _DesktopSendFeeFormState extends ConsumerState { .estimateFeeFor(amount, feeRate); } } else { - // Token fee estimation (works for ERC20 and SPL tokens). + // Token fee estimation (works for ERC20 and SOL tokens). try { final tokenWallet = ref.read( pCurrentTokenWallet, diff --git a/lib/services/price.dart b/lib/services/price.dart index eed74f789..abf47795c 100644 --- a/lib/services/price.dart +++ b/lib/services/price.dart @@ -301,7 +301,7 @@ class PriceAPI { } } - /// Get prices and 24h change for Solana SPL tokens. + /// Get prices and 24h change for Solana SOL tokens. /// /// Uses CoinGecko API to fetch prices for tokens by their Solana mint addresses. /// Format: GET /api/v3/simple/token_price/solana?vs_currencies=usd&contract_addresses=mint1,mint2&include_24hr_change=true diff --git a/lib/utilities/amount/amount_unit.dart b/lib/utilities/amount/amount_unit.dart index 2ddfe8360..2b320abc1 100644 --- a/lib/utilities/amount/amount_unit.dart +++ b/lib/utilities/amount/amount_unit.dart @@ -192,7 +192,7 @@ extension AmountUnitExt on AmountUnit { case AmountUnit.yocto: case AmountUnit.ronto: case AmountUnit.quecto: - // For SPL tokens, just use the symbol with the prefix if applicable. + // For SOL tokens, just use the symbol with the prefix if applicable. return token.symbol; } } From 659f76f1e42cfe54708c9487dd43d86c14ea09b5 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 26 Nov 2025 16:12:43 -0600 Subject: [PATCH 77/80] fix(spl): Set->List to match Eth --- .../add_token_view/edit_wallet_tokens_view.dart | 2 +- lib/wallets/isar/models/wallet_info.dart | 2 +- lib/wallets/wallet/impl/solana_wallet.dart | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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 2d4f869bb..bace01e5e 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 @@ -316,7 +316,7 @@ class _EditWalletTokensViewState extends ConsumerState { // 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(); + final currentCustomTokens = wallet.info.solanaCustomTokenMintAddresses; currentCustomTokens.add(token.address); await wallet.info.updateSolanaCustomTokenMintAddresses( newMintAddresses: currentCustomTokens, diff --git a/lib/wallets/isar/models/wallet_info.dart b/lib/wallets/isar/models/wallet_info.dart index a6747b5bd..db0a65c70 100644 --- a/lib/wallets/isar/models/wallet_info.dart +++ b/lib/wallets/isar/models/wallet_info.dart @@ -437,7 +437,7 @@ class WalletInfo implements IsarId { /// Update custom Solana token mint addresses and update the db. Future updateSolanaCustomTokenMintAddresses({ - required Set newMintAddresses, + required List newMintAddresses, required Isar isar, }) async { await updateOtherData( diff --git a/lib/wallets/wallet/impl/solana_wallet.dart b/lib/wallets/wallet/impl/solana_wallet.dart index 01969e136..e9b9fccee 100644 --- a/lib/wallets/wallet/impl/solana_wallet.dart +++ b/lib/wallets/wallet/impl/solana_wallet.dart @@ -645,7 +645,7 @@ class SolanaWallet extends Bip39Wallet { return false; } - Future updateSolanaTokens(Set mintAddresses) async { + Future updateSolanaTokens(List mintAddresses) async { await info.updateSolanaCustomTokenMintAddresses( newMintAddresses: mintAddresses, isar: mainDB.isar, From 14c506c44859a95b252ca2cd64fd629ebe84ae9c Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 26 Nov 2025 16:14:31 -0600 Subject: [PATCH 78/80] fix(spl): handle Sol tokens the same way Eth tokens are --- .../edit_wallet_tokens_view.dart | 142 +++--------------- .../add_wallet_view/add_wallet_view.dart | 19 ++- 2 files changed, 34 insertions(+), 127 deletions(-) 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 bace01e5e..1460f180f 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 @@ -19,7 +19,6 @@ import '../../../db/isar/main_db.dart'; import '../../../models/isar/models/ethereum/eth_contract.dart'; import '../../../models/isar/models/solana/spl_token.dart'; import '../../../notifications/show_flush_bar.dart'; -import '../../../services/solana/solana_token_api.dart'; import '../../../pages_desktop_specific/desktop_home_view.dart'; import '../../../providers/global/price_provider.dart'; import '../../../providers/global/wallets_provider.dart'; @@ -109,112 +108,11 @@ class _EditWalletTokensViewState extends ConsumerState { final wallet = ref.read(pWallets).getWallet(widget.walletId); - // Handle Ethereum tokens. + // Handle 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; - - // Separate selected tokens into default and custom. - final defaultTokenMints = DefaultSplTokens.list.map((e) => e.address).toSet(); - final selectedDefaultTokens = selectedTokens.where( - (mint) => defaultTokenMints.contains(mint), - ).toSet(); - final selectedCustomTokens = selectedTokens.where( - (mint) => !defaultTokenMints.contains(mint), - ).toSet(); - - // Update default token mint addresses. - await walletInfo.updateSolanaTokenMintAddresses( - newMintAddresses: selectedDefaultTokens, - isar: MainDB.instance.isar, - ); - - // Update custom token mint addresses. - await walletInfo.updateSolanaCustomTokenMintAddresses( - newMintAddresses: selectedCustomTokens, - 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 { - // Initialize the RPC client for the SolanaTokenAPI. - final tokenApi = SolanaTokenAPI(); - final rpcClient = wallet.getRpcClient(); - - if (rpcClient != null) { - tokenApi.initializeRpcClient(rpcClient); - - 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}', - ); - } - } else { - debugPrint('Warning: RPC client not initialized for wallet'); - } - } catch (e) { - debugPrint('Exception checking ownership: $e'); - } - } - - debugPrint('========================================'); - } catch (e) { - debugPrint('Error getting wallet address: $e'); - } + } else if (wallet is SolanaWallet) { + await wallet.updateSolanaTokens(selectedTokens); } if (mounted) { if (widget.contractsToMarkSelected == null) { @@ -347,29 +245,23 @@ class _EditWalletTokensViewState extends ConsumerState { final wallet = ref.read(pWallets).getWallet(widget.walletId); - // Load appropriate tokens based on wallet type. if (wallet is SolanaWallet) { - // Load both default and custom Solana tokens. - final defaultSplTokens = DefaultSplTokens.list; - tokenEntities.addAll(defaultSplTokens.map((e) => AddTokenListElementData(e))); - - // Load custom tokens from database - final customSplTokens = MainDB.instance.getSplTokens().findAllSync(); - - // Deduplicate: only add custom tokens that aren't already in defaults. - final seenAddresses = { - ...defaultSplTokens.map((e) => e.address), - ...tokenEntities.map((e) => e.token.address), - }; - - for (final token in customSplTokens) { - if (!seenAddresses.contains(token.address)) { - tokenEntities.add(AddTokenListElementData(token)); - seenAddresses.add(token.address); - } + final contracts = MainDB.instance + .getSplTokens() + .sortByName() + .findAllSync(); + + if (contracts.isEmpty) { + contracts.addAll(DefaultSplTokens.list); + MainDB.instance + .putSplTokens(contracts) + .then( + (_) => ref.read(priceAnd24hChangeNotifierProvider).updatePrice(), + ); } + + tokenEntities.addAll(contracts.map((e) => AddTokenListElementData(e))); } else { - // Load Ethereum tokens (default behavior for Ethereum wallets). final contracts = MainDB.instance .getEthContracts() .sortByName() 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 c1db3b5eb..4f94e0571 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 @@ -29,6 +29,7 @@ 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'; @@ -192,8 +193,22 @@ class _AddWalletViewState extends ConsumerState { } if (AppConfig.coins.whereType().isNotEmpty) { - final tokens = MainDB.instance.getSplTokens().findAllSync(); - solTokenEntities.addAll(tokens.map((e) => SolTokenEntity(e))); + final contracts = MainDB.instance + .getSplTokens() + .sortByName() + .findAllSync(); + + if (contracts.isEmpty) { + contracts.addAll(DefaultSplTokens.list); + MainDB.instance + .putSplTokens(contracts) + .then( + (value) => + ref.read(priceAnd24hChangeNotifierProvider).updatePrice(), + ); + } + + solTokenEntities.addAll(contracts.map((e) => SolTokenEntity(e))); } WidgetsBinding.instance.addPostFrameCallback((_) { From 70e42e84ac7ad97e18db2004e45a49e4e6e8663e Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 26 Nov 2025 17:03:41 -0600 Subject: [PATCH 79/80] refactor(spl): align DefaultSplTokens usage w/ DefaultTokens --- lib/pages/token_view/sol_token_view.dart | 71 ---------------- .../solana_token_contract_details_view.dart | 27 +------ .../sub_widgets/sol_token_select_item.dart | 81 ++++++++++++++++++- lib/pages/wallets_view/wallets_overview.dart | 29 +------ .../wallet_view/desktop_sol_token_view.dart | 64 --------------- .../sub_widgets/desktop_wallet_summary.dart | 2 +- 6 files changed, 85 insertions(+), 189 deletions(-) diff --git a/lib/pages/token_view/sol_token_view.dart b/lib/pages/token_view/sol_token_view.dart index 242a6cdf5..325359524 100644 --- a/lib/pages/token_view/sol_token_view.dart +++ b/lib/pages/token_view/sol_token_view.dart @@ -13,18 +13,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; import 'package:tuple/tuple.dart'; -import '../../models/isar/models/isar_models.dart'; -import '../../providers/db/main_db_provider.dart'; -import '../../providers/providers.dart'; import '../../services/event_bus/events/global/wallet_sync_status_changed_event.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; import '../../utilities/constants.dart'; -import '../../utilities/default_spl_tokens.dart'; import '../../utilities/text_styles.dart'; import '../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; import '../../wallets/isar/providers/solana/solana_wallet_provider.dart'; -import '../../wallets/wallet/impl/sub_wallets/solana_token_wallet.dart'; import '../../widgets/background.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../widgets/custom_buttons/blue_text_button.dart'; @@ -65,75 +60,9 @@ class _SolTokenViewState extends ConsumerState { ? WalletSyncStatus.syncing : WalletSyncStatus.synced; - // Initialize the Solana token wallet provider with mock data. - // - // This sets up the pCurrentSolanaTokenWallet provider so that - // SolanaTokenSummary can access the token wallet information. - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _initializeSolanaTokenWallet(); - } - }); - super.initState(); } - /// Initialize the Solana token wallet for this token view. - /// - /// Creates a SolanaTokenWallet with token data from DefaultSplTokens or the database. - /// First looks in DefaultSplTokens, then checks the database for custom tokens. - /// Sets it as the current token wallet in the provider so that UI widgets can access it. - /// - /// If the token is not found anywhere, sets the token wallet to null - /// so the UI can display an error message. - void _initializeSolanaTokenWallet() { - SplToken? tokenInfo; - - // First try to find in default tokens. - try { - tokenInfo = DefaultSplTokens.list.firstWhere( - (token) => token.address == widget.tokenMint, - ); - } catch (e) { - // Token not found in DefaultSplTokens, try database for custom tokens. - tokenInfo = null; - } - - // If not found in defaults, try database for custom tokens. - if (tokenInfo == null) { - try { - final db = ref.read(mainDBProvider); - tokenInfo = db.getSplTokenSync(widget.tokenMint); - } catch (e) { - tokenInfo = null; - } - } - - if (tokenInfo == null) { - ref.read(solanaTokenServiceStateProvider.state).state = null; - debugPrint( - 'ERROR: Token not found in DefaultSplTokens or database: ${widget.tokenMint}', - ); - return; - } - - // Get the parent Solana wallet. - final parentWallet = ref.read(pSolanaWallet(widget.walletId)); - - if (parentWallet == null) { - ref.read(solanaTokenServiceStateProvider.state).state = null; - debugPrint('ERROR: Wallet is not a SolanaWallet: ${widget.walletId}'); - return; - } - - final solanaTokenWallet = SolanaTokenWallet(parentWallet, tokenInfo); - - ref.read(solanaTokenServiceStateProvider.state).state = solanaTokenWallet; - - // Fetch the token balance when the wallet is opened. - solanaTokenWallet.updateBalance(); - } - @override void dispose() { super.dispose(); diff --git a/lib/pages/token_view/solana_token_contract_details_view.dart b/lib/pages/token_view/solana_token_contract_details_view.dart index c0b74050b..eadc8c640 100644 --- a/lib/pages/token_view/solana_token_contract_details_view.dart +++ b/lib/pages/token_view/solana_token_contract_details_view.dart @@ -14,7 +14,6 @@ import 'package:isar_community/isar.dart'; import '../../db/isar/main_db.dart'; import '../../models/isar/models/isar_models.dart'; import '../../themes/stack_colors.dart'; -import '../../utilities/default_spl_tokens.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../widgets/background.dart'; @@ -48,32 +47,10 @@ class _SolanaTokenContractDetailsViewState @override void initState() { - // Try to find the token in the database first. - final dbToken = MainDB.instance.isar.splTokens + token = MainDB.instance.isar.splTokens .where() .addressEqualTo(widget.tokenMint) - .findFirstSync(); - - if (dbToken != null) { - token = dbToken; - } else { - // If not in database, try to find it in default tokens. - try { - token = DefaultSplTokens.list.firstWhere( - (t) => t.address == widget.tokenMint, - ); - } catch (e) { - // Token not found, create a placeholder. - // - // Might want to just throw here instead. - token = SplToken( - address: widget.tokenMint, - name: 'Unknown Token', - symbol: 'UNKNOWN', - decimals: 0, - ); - } - } + .findFirstSync()!; super.initState(); } diff --git a/lib/pages/token_view/sub_widgets/sol_token_select_item.dart b/lib/pages/token_view/sub_widgets/sol_token_select_item.dart index b7772a666..50a5179e1 100644 --- a/lib/pages/token_view/sub_widgets/sol_token_select_item.dart +++ b/lib/pages/token_view/sub_widgets/sol_token_select_item.dart @@ -7,6 +7,8 @@ * */ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -15,9 +17,15 @@ import '../../../pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_to import '../../../providers/providers.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/constants.dart'; +import '../../../utilities/show_loading.dart'; import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; +import '../../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; import '../../../wallets/isar/providers/solana/sol_token_balance_provider.dart'; +import '../../../wallets/wallet/impl/solana_wallet.dart'; +import '../../../wallets/wallet/impl/sub_wallets/solana_token_wallet.dart'; +import '../../../widgets/desktop/primary_button.dart'; +import '../../../widgets/dialogs/basic_dialog.dart'; import '../../../widgets/icon_widgets/sol_token_icon.dart'; import '../../../widgets/rounded_white_container.dart'; import '../sol_token_view.dart'; @@ -39,9 +47,80 @@ class SolTokenSelectItem extends ConsumerStatefulWidget { class _SolTokenSelectItemState extends ConsumerState { final bool isDesktop = Util.isDesktop; + Future _loadTokenWallet(BuildContext context, WidgetRef ref) async { + try { + await ref.read(pCurrentSolanaTokenWallet)!.init(); + return true; + } catch (_) { + await showDialog( + barrierDismissible: false, + context: context, + builder: (context) => BasicDialog( + title: "Failed to load token data", + desktopHeight: double.infinity, + desktopWidth: 450, + rightButton: PrimaryButton( + label: "OK", + onPressed: () { + Navigator.of(context).pop(); + if (!isDesktop) { + Navigator.of(context).pop(); + } + }, + ), + ), + ); + return false; + } + } + void _onPressed() async { - // TODO [prio=high]: Implement Solana token wallet setup and navigation. + final old = ref.read(solanaTokenServiceStateProvider); + // exit previous if there is one + unawaited(old?.exit()); + + // Get the parent Solana wallet. + final solanaWallet = + ref.read(pWallets).getWallet(widget.walletId) as SolanaWallet?; + if (solanaWallet == null) { + if (mounted) { + await showDialog( + barrierDismissible: false, + context: context, + builder: (context) => BasicDialog( + title: "Error: Parent Solana wallet not found", + desktopHeight: double.infinity, + desktopWidth: 450, + rightButton: PrimaryButton( + label: "OK", + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ), + ); + } + return; + } + + ref.read(solanaTokenServiceStateProvider.state).state = SolanaTokenWallet( + solanaWallet, + widget.token, + ); + + final success = await showLoading( + whileFuture: _loadTokenWallet(context, ref), + context: context, + rootNavigator: isDesktop, + message: "Loading ${widget.token.name}", + ); + + if (!success!) { + return; + } + if (mounted) { + unawaited(ref.read(pCurrentSolanaTokenWallet)!.refresh()); await Navigator.of(context).pushNamed( isDesktop ? DesktopSolTokenView.routeName : SolTokenView.routeName, arguments: ( diff --git a/lib/pages/wallets_view/wallets_overview.dart b/lib/pages/wallets_view/wallets_overview.dart index 3878cfbce..675838d4c 100644 --- a/lib/pages/wallets_view/wallets_overview.dart +++ b/lib/pages/wallets_view/wallets_overview.dart @@ -8,8 +8,6 @@ * */ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; @@ -26,7 +24,6 @@ import '../../services/event_bus/global_event_bus.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; import '../../utilities/constants.dart'; -import '../../utilities/default_spl_tokens.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; @@ -159,16 +156,6 @@ class _EthWalletsOverviewState extends ConsumerState { ); } } else if (widget.coin is Solana) { - // Ensure default Solana tokens are loaded into database. - final dbProvider = ref.read(mainDBProvider); - for (final defaultToken in DefaultSplTokens.list) { - final existingToken = dbProvider.getSplTokenSync(defaultToken.address); - if (existingToken == null) { - // Token not in database, add it asynchronously. - unawaited(dbProvider.putSplToken(defaultToken)); - } - } - for (final data in walletsData) { final List contracts = []; final tokenMintAddresses = ref.read( @@ -177,23 +164,11 @@ class _EthWalletsOverviewState extends ConsumerState { // fetch each token for (final tokenAddress in tokenMintAddresses) { - final token = dbProvider.getSplTokenSync(tokenAddress); + final token = ref.read(mainDBProvider).getSplTokenSync(tokenAddress); - // add it to list if it exists in DB or in default tokens + // add it to list if it exists in DB if (token != null) { contracts.add(token); - } else { - // Try to find in default tokens. - try { - final defaultToken = DefaultSplTokens.list.firstWhere( - (t) => t.address == tokenAddress, - ); - contracts.add(defaultToken); - } catch (_) { - // Token not found anywhere. - // - // Might want to throw here or something. - } } } diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart index ce1978460..12dab0b07 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart @@ -13,21 +13,16 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; import 'package:tuple/tuple.dart'; -import '../../../models/isar/models/isar_models.dart'; import '../../../pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart'; import '../../../pages/token_view/solana_token_contract_details_view.dart'; import '../../../pages/token_view/sub_widgets/token_transaction_list_widget_sol.dart'; -import '../../../providers/db/main_db_provider.dart'; -import '../../../providers/providers.dart'; import '../../../services/event_bus/events/global/wallet_sync_status_changed_event.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/assets.dart'; -import '../../../utilities/default_spl_tokens.dart'; import '../../../utilities/text_styles.dart'; import '../../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; import '../../../wallets/isar/providers/solana/solana_wallet_provider.dart'; import '../../../wallets/isar/providers/wallet_info_provider.dart'; -import '../../../wallets/wallet/impl/sub_wallets/solana_token_wallet.dart'; import '../../../widgets/coin_ticker_tag.dart'; import '../../../widgets/custom_buttons/blue_text_button.dart'; import '../../../widgets/desktop/desktop_app_bar.dart'; @@ -65,10 +60,6 @@ class _DesktopTokenViewState extends ConsumerState { @override void initState() { - // Initialize the Solana token wallet. - WidgetsBinding.instance.addPostFrameCallback((_) { - _initializeSolanaTokenWallet(); - }); // Get the initial sync status from the Solana wallet's refresh mutex. final solanaWallet = ref.read(pSolanaWallet(widget.walletId)); initialSyncStatus = solanaWallet?.refreshMutex.isLocked ?? false @@ -77,61 +68,6 @@ class _DesktopTokenViewState extends ConsumerState { super.initState(); } - /// Initialize the Solana token wallet. - /// - /// Creates a SolanaTokenWallet with token data from DefaultSplTokens or the database. - /// First looks in DefaultSplTokens, then checks the database for custom tokens. - /// Sets it as the current token wallet in the provider so that UI widgets can access it. - /// - /// If the token is not found anywhere, sets the token wallet to null - /// so the UI can display an error message. - void _initializeSolanaTokenWallet() { - // First try to find in default tokens - SplToken? tokenInfo; - try { - tokenInfo = DefaultSplTokens.list.firstWhere( - (token) => token.address == widget.tokenMint, - ); - } catch (e) { - // Token not found in DefaultSplTokens, try database for custom tokens - tokenInfo = null; - } - - // If not found in defaults, try database for custom tokens - if (tokenInfo == null) { - try { - final db = ref.read(mainDBProvider); - tokenInfo = db.getSplTokenSync(widget.tokenMint); - } catch (e) { - tokenInfo = null; - } - } - - if (tokenInfo == null) { - ref.read(solanaTokenServiceStateProvider.state).state = null; - debugPrint( - 'ERROR: Token not found in DefaultSplTokens or database: ${widget.tokenMint}', - ); - return; - } - - // Get the parent Solana wallet. - final parentWallet = ref.read(pSolanaWallet(widget.walletId)); - - if (parentWallet == null) { - ref.read(solanaTokenServiceStateProvider.state).state = null; - debugPrint('ERROR: Wallet is not a SolanaWallet: ${widget.walletId}'); - return; - } - - final solanaTokenWallet = SolanaTokenWallet(parentWallet, tokenInfo); - - ref.read(solanaTokenServiceStateProvider.state).state = solanaTokenWallet; - - // Fetch the token balance when the wallet is opened - solanaTokenWallet.updateBalance(); - } - @override void dispose() { super.dispose(); diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_summary.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_summary.dart index 99c671dd0..b538eb474 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_summary.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_summary.dart @@ -99,7 +99,7 @@ class _WDesktopWalletSummaryState extends ConsumerState { tokenContract = null; // this cannot be null if coin is sol and isToken is true. // if it is null, then there is a bug somewhere else. - solanaTokenWallet = ref.watch(pCurrentSolanaTokenWallet)!; + solanaTokenWallet = ref.watch(pCurrentSolanaTokenWallet); break; default: From 8d5cd8f4133946b3180d29de56aeb2313a664d21 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 26 Nov 2025 17:52:12 -0600 Subject: [PATCH 80/80] refactor(spl) "spl"->"sol" where appropriate --- lib/db/isar/main_db.dart | 25 +- .../sub_classes/sol_token_entity.dart | 6 +- lib/models/isar/models/isar_models.dart | 2 +- .../{spl_token.dart => sol_contract.dart} | 10 +- .../{spl_token.g.dart => sol_contract.g.dart} | 358 +++++++++--------- .../add_custom_solana_token_view.dart | 12 +- .../edit_wallet_tokens_view.dart | 16 +- .../add_wallet_view/add_wallet_view.dart | 16 +- .../send_view/confirm_transaction_view.dart | 10 +- .../solana_token_contract_details_view.dart | 4 +- .../sub_widgets/sol_token_select_item.dart | 4 +- .../sub_widgets/sol_tokens_list.dart | 6 +- lib/pages/wallets_view/wallets_overview.dart | 4 +- .../sub_widgets/desktop_wallet_summary.dart | 2 +- lib/services/price_service.dart | 2 +- lib/utilities/amount/amount_formatter.dart | 6 +- lib/utilities/amount/amount_unit.dart | 6 +- lib/utilities/default_sol_tokens.dart | 55 +++ lib/utilities/default_spl_tokens.dart | 50 --- .../isar/models/wallet_solana_token_info.dart | 4 +- .../solana/sol_token_balance_provider.dart | 6 +- .../providers/solana/sol_tokens_provider.dart | 2 +- .../impl/sub_wallets/solana_token_wallet.dart | 14 +- lib/widgets/icon_widgets/sol_token_icon.dart | 4 +- lib/widgets/wallet_card.dart | 8 +- .../sub_widgets/wallet_info_row_balance.dart | 6 +- .../wallet_info_row/wallet_info_row.dart | 9 +- .../transaction_card_test.mocks.dart | 38 +- 28 files changed, 356 insertions(+), 329 deletions(-) rename lib/models/isar/models/solana/{spl_token.dart => sol_contract.dart} (90%) rename lib/models/isar/models/solana/{spl_token.g.dart => sol_contract.g.dart} (74%) create mode 100644 lib/utilities/default_sol_tokens.dart delete mode 100644 lib/utilities/default_spl_tokens.dart diff --git a/lib/db/isar/main_db.dart b/lib/db/isar/main_db.dart index a3a18a7f1..be46edf02 100644 --- a/lib/db/isar/main_db.dart +++ b/lib/db/isar/main_db.dart @@ -59,7 +59,7 @@ class MainDB { AddressSchema, AddressLabelSchema, EthContractSchema, - SplTokenSchema, + SolContractSchema, TransactionBlockExplorerSchema, StackThemeSchema, ContactEntrySchema, @@ -628,20 +628,21 @@ class MainDB { // Solana tokens. - QueryBuilder getSplTokens() => - isar.splTokens.where(); + QueryBuilder getSolContracts() => + isar.solContracts.where(); - Future getSplToken(String tokenMint) => - isar.splTokens.where().addressEqualTo(tokenMint).findFirst(); + Future getSolContract(String tokenMint) => + isar.solContracts.where().addressEqualTo(tokenMint).findFirst(); - SplToken? getSplTokenSync(String tokenMint) => - isar.splTokens.where().addressEqualTo(tokenMint).findFirstSync(); + SolContract? getSolContractSync(String tokenMint) => + isar.solContracts.where().addressEqualTo(tokenMint).findFirstSync(); - Future putSplToken(SplToken token) => isar.writeTxn(() async { - return await isar.splTokens.put(token); + Future putSolContract(SolContract token) => isar.writeTxn(() async { + return await isar.solContracts.put(token); }); - Future putSplTokens(List tokens) => isar.writeTxn(() async { - await isar.splTokens.putAll(tokens); - }); + Future putSolContracts(List tokens) => + isar.writeTxn(() async { + await isar.solContracts.putAll(tokens); + }); } 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 index f7a35ca7b..1f4c01ff0 100644 --- 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 @@ -7,14 +7,14 @@ * */ -import '../add_wallet_list_entity.dart'; -import '../../isar/models/solana/spl_token.dart'; import '../../../wallets/crypto_currency/crypto_currency.dart'; +import '../../isar/models/solana/sol_contract.dart'; +import '../add_wallet_list_entity.dart'; class SolTokenEntity extends AddWalletListEntity { SolTokenEntity(this.token); - final SplToken token; + final SolContract token; @override CryptoCurrency get cryptoCurrency => Solana(CryptoCurrencyNetwork.main); diff --git a/lib/models/isar/models/isar_models.dart b/lib/models/isar/models/isar_models.dart index eb61a82b9..cf27091bf 100644 --- a/lib/models/isar/models/isar_models.dart +++ b/lib/models/isar/models/isar_models.dart @@ -16,6 +16,6 @@ export 'blockchain_data/transaction.dart'; export 'blockchain_data/utxo.dart'; export 'ethereum/eth_contract.dart'; export 'log.dart'; -export 'solana/spl_token.dart'; +export 'solana/sol_contract.dart'; export 'transaction_note.dart'; export '../../../wallets/isar/models/wallet_solana_token_info.dart'; diff --git a/lib/models/isar/models/solana/spl_token.dart b/lib/models/isar/models/solana/sol_contract.dart similarity index 90% rename from lib/models/isar/models/solana/spl_token.dart rename to lib/models/isar/models/solana/sol_contract.dart index 218001d76..ba2493e2a 100644 --- a/lib/models/isar/models/solana/spl_token.dart +++ b/lib/models/isar/models/solana/sol_contract.dart @@ -11,11 +11,11 @@ import 'package:isar_community/isar.dart'; import '../contract.dart'; -part 'spl_token.g.dart'; +part 'sol_contract.g.dart'; @collection -class SplToken extends Contract { - SplToken({ +class SolContract extends Contract { + SolContract({ required this.address, required this.name, required this.symbol, @@ -43,7 +43,7 @@ class SplToken extends Contract { late final String? metadataAddress; - SplToken copyWith({ + SolContract copyWith({ Id? id, String? address, String? name, @@ -51,7 +51,7 @@ class SplToken extends Contract { int? decimals, String? logoUri, String? metadataAddress, - }) => SplToken( + }) => SolContract( address: address ?? this.address, name: name ?? this.name, symbol: symbol ?? this.symbol, diff --git a/lib/models/isar/models/solana/spl_token.g.dart b/lib/models/isar/models/solana/sol_contract.g.dart similarity index 74% rename from lib/models/isar/models/solana/spl_token.g.dart rename to lib/models/isar/models/solana/sol_contract.g.dart index 317e34bf2..b75906e25 100644 --- a/lib/models/isar/models/solana/spl_token.g.dart +++ b/lib/models/isar/models/solana/sol_contract.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'spl_token.dart'; +part of 'sol_contract.dart'; // ************************************************************************** // IsarCollectionGenerator @@ -9,13 +9,13 @@ part of 'spl_token.dart'; // coverage:ignore-file // ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types -extension GetSplTokenCollection on Isar { - IsarCollection get splTokens => this.collection(); +extension GetSolContractCollection on Isar { + IsarCollection get solContracts => this.collection(); } -const SplTokenSchema = CollectionSchema( - name: r'SplToken', - id: 8546297717864199659, +const SolContractSchema = CollectionSchema( + name: r'SolContract', + id: 1474803837279318906, properties: { r'address': PropertySchema(id: 0, name: r'address', type: IsarType.string), r'decimals': PropertySchema(id: 1, name: r'decimals', type: IsarType.long), @@ -29,10 +29,10 @@ const SplTokenSchema = CollectionSchema( r'symbol': PropertySchema(id: 5, name: r'symbol', type: IsarType.string), }, - estimateSize: _splTokenEstimateSize, - serialize: _splTokenSerialize, - deserialize: _splTokenDeserialize, - deserializeProp: _splTokenDeserializeProp, + estimateSize: _solContractEstimateSize, + serialize: _solContractSerialize, + deserialize: _solContractDeserialize, + deserializeProp: _solContractDeserializeProp, idName: r'id', indexes: { r'address': IndexSchema( @@ -52,14 +52,14 @@ const SplTokenSchema = CollectionSchema( links: {}, embeddedSchemas: {}, - getId: _splTokenGetId, - getLinks: _splTokenGetLinks, - attach: _splTokenAttach, + getId: _solContractGetId, + getLinks: _solContractGetLinks, + attach: _solContractAttach, version: '3.3.0-dev.2', ); -int _splTokenEstimateSize( - SplToken object, +int _solContractEstimateSize( + SolContract object, List offsets, Map> allOffsets, ) { @@ -82,8 +82,8 @@ int _splTokenEstimateSize( return bytesCount; } -void _splTokenSerialize( - SplToken object, +void _solContractSerialize( + SolContract object, IsarWriter writer, List offsets, Map> allOffsets, @@ -96,13 +96,13 @@ void _splTokenSerialize( writer.writeString(offsets[5], object.symbol); } -SplToken _splTokenDeserialize( +SolContract _solContractDeserialize( Id id, IsarReader reader, List offsets, Map> allOffsets, ) { - final object = SplToken( + final object = SolContract( address: reader.readString(offsets[0]), decimals: reader.readLong(offsets[1]), logoUri: reader.readStringOrNull(offsets[2]), @@ -114,7 +114,7 @@ SplToken _splTokenDeserialize( return object; } -P _splTokenDeserializeProp

( +P _solContractDeserializeProp

( IsarReader reader, int propertyId, int offset, @@ -138,24 +138,28 @@ P _splTokenDeserializeProp

( } } -Id _splTokenGetId(SplToken object) { +Id _solContractGetId(SolContract object) { return object.id; } -List> _splTokenGetLinks(SplToken object) { +List> _solContractGetLinks(SolContract object) { return []; } -void _splTokenAttach(IsarCollection col, Id id, SplToken object) { +void _solContractAttach( + IsarCollection col, + Id id, + SolContract object, +) { object.id = id; } -extension SplTokenByIndex on IsarCollection { - Future getByAddress(String address) { +extension SolContractByIndex on IsarCollection { + Future getByAddress(String address) { return getByIndex(r'address', [address]); } - SplToken? getByAddressSync(String address) { + SolContract? getByAddressSync(String address) { return getByIndexSync(r'address', [address]); } @@ -167,12 +171,12 @@ extension SplTokenByIndex on IsarCollection { return deleteByIndexSync(r'address', [address]); } - Future> getAllByAddress(List addressValues) { + Future> getAllByAddress(List addressValues) { final values = addressValues.map((e) => [e]).toList(); return getAllByIndex(r'address', values); } - List getAllByAddressSync(List addressValues) { + List getAllByAddressSync(List addressValues) { final values = addressValues.map((e) => [e]).toList(); return getAllByIndexSync(r'address', values); } @@ -187,42 +191,46 @@ extension SplTokenByIndex on IsarCollection { return deleteAllByIndexSync(r'address', values); } - Future putByAddress(SplToken object) { + Future putByAddress(SolContract object) { return putByIndex(r'address', object); } - Id putByAddressSync(SplToken object, {bool saveLinks = true}) { + Id putByAddressSync(SolContract object, {bool saveLinks = true}) { return putByIndexSync(r'address', object, saveLinks: saveLinks); } - Future> putAllByAddress(List objects) { + Future> putAllByAddress(List objects) { return putAllByIndex(r'address', objects); } List putAllByAddressSync( - List objects, { + List objects, { bool saveLinks = true, }) { return putAllByIndexSync(r'address', objects, saveLinks: saveLinks); } } -extension SplTokenQueryWhereSort on QueryBuilder { - QueryBuilder anyId() { +extension SolContractQueryWhereSort + on QueryBuilder { + QueryBuilder anyId() { return QueryBuilder.apply(this, (query) { return query.addWhereClause(const IdWhereClause.any()); }); } } -extension SplTokenQueryWhere on QueryBuilder { - QueryBuilder idEqualTo(Id id) { +extension SolContractQueryWhere + on QueryBuilder { + QueryBuilder idEqualTo(Id id) { return QueryBuilder.apply(this, (query) { return query.addWhereClause(IdWhereClause.between(lower: id, upper: id)); }); } - QueryBuilder idNotEqualTo(Id id) { + QueryBuilder idNotEqualTo( + Id id, + ) { return QueryBuilder.apply(this, (query) { if (query.whereSort == Sort.asc) { return query @@ -244,7 +252,7 @@ extension SplTokenQueryWhere on QueryBuilder { }); } - QueryBuilder idGreaterThan( + QueryBuilder idGreaterThan( Id id, { bool include = false, }) { @@ -255,7 +263,7 @@ extension SplTokenQueryWhere on QueryBuilder { }); } - QueryBuilder idLessThan( + QueryBuilder idLessThan( Id id, { bool include = false, }) { @@ -266,7 +274,7 @@ extension SplTokenQueryWhere on QueryBuilder { }); } - QueryBuilder idBetween( + QueryBuilder idBetween( Id lowerId, Id upperId, { bool includeLower = true, @@ -284,7 +292,7 @@ extension SplTokenQueryWhere on QueryBuilder { }); } - QueryBuilder addressEqualTo( + QueryBuilder addressEqualTo( String address, ) { return QueryBuilder.apply(this, (query) { @@ -294,7 +302,7 @@ extension SplTokenQueryWhere on QueryBuilder { }); } - QueryBuilder addressNotEqualTo( + QueryBuilder addressNotEqualTo( String address, ) { return QueryBuilder.apply(this, (query) { @@ -339,9 +347,9 @@ extension SplTokenQueryWhere on QueryBuilder { } } -extension SplTokenQueryFilter - on QueryBuilder { - QueryBuilder addressEqualTo( +extension SolContractQueryFilter + on QueryBuilder { + QueryBuilder addressEqualTo( String value, { bool caseSensitive = true, }) { @@ -356,7 +364,8 @@ extension SplTokenQueryFilter }); } - QueryBuilder addressGreaterThan( + QueryBuilder + addressGreaterThan( String value, { bool include = false, bool caseSensitive = true, @@ -373,7 +382,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder addressLessThan( + QueryBuilder addressLessThan( String value, { bool include = false, bool caseSensitive = true, @@ -390,7 +399,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder addressBetween( + QueryBuilder addressBetween( String lower, String upper, { bool includeLower = true, @@ -411,10 +420,8 @@ extension SplTokenQueryFilter }); } - QueryBuilder addressStartsWith( - String value, { - bool caseSensitive = true, - }) { + QueryBuilder + addressStartsWith(String value, {bool caseSensitive = true}) { return QueryBuilder.apply(this, (query) { return query.addFilterCondition( FilterCondition.startsWith( @@ -426,7 +433,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder addressEndsWith( + QueryBuilder addressEndsWith( String value, { bool caseSensitive = true, }) { @@ -441,7 +448,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder addressContains( + QueryBuilder addressContains( String value, { bool caseSensitive = true, }) { @@ -456,7 +463,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder addressMatches( + QueryBuilder addressMatches( String pattern, { bool caseSensitive = true, }) { @@ -471,7 +478,8 @@ extension SplTokenQueryFilter }); } - QueryBuilder addressIsEmpty() { + QueryBuilder + addressIsEmpty() { return QueryBuilder.apply(this, (query) { return query.addFilterCondition( FilterCondition.equalTo(property: r'address', value: ''), @@ -479,7 +487,8 @@ extension SplTokenQueryFilter }); } - QueryBuilder addressIsNotEmpty() { + QueryBuilder + addressIsNotEmpty() { return QueryBuilder.apply(this, (query) { return query.addFilterCondition( FilterCondition.greaterThan(property: r'address', value: ''), @@ -487,7 +496,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder decimalsEqualTo( + QueryBuilder decimalsEqualTo( int value, ) { return QueryBuilder.apply(this, (query) { @@ -497,10 +506,8 @@ extension SplTokenQueryFilter }); } - QueryBuilder decimalsGreaterThan( - int value, { - bool include = false, - }) { + QueryBuilder + decimalsGreaterThan(int value, {bool include = false}) { return QueryBuilder.apply(this, (query) { return query.addFilterCondition( FilterCondition.greaterThan( @@ -512,10 +519,8 @@ extension SplTokenQueryFilter }); } - QueryBuilder decimalsLessThan( - int value, { - bool include = false, - }) { + QueryBuilder + decimalsLessThan(int value, {bool include = false}) { return QueryBuilder.apply(this, (query) { return query.addFilterCondition( FilterCondition.lessThan( @@ -527,7 +532,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder decimalsBetween( + QueryBuilder decimalsBetween( int lower, int upper, { bool includeLower = true, @@ -546,7 +551,9 @@ extension SplTokenQueryFilter }); } - QueryBuilder idEqualTo(Id value) { + QueryBuilder idEqualTo( + Id value, + ) { return QueryBuilder.apply(this, (query) { return query.addFilterCondition( FilterCondition.equalTo(property: r'id', value: value), @@ -554,7 +561,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder idGreaterThan( + QueryBuilder idGreaterThan( Id value, { bool include = false, }) { @@ -569,7 +576,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder idLessThan( + QueryBuilder idLessThan( Id value, { bool include = false, }) { @@ -584,7 +591,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder idBetween( + QueryBuilder idBetween( Id lower, Id upper, { bool includeLower = true, @@ -603,7 +610,8 @@ extension SplTokenQueryFilter }); } - QueryBuilder logoUriIsNull() { + QueryBuilder + logoUriIsNull() { return QueryBuilder.apply(this, (query) { return query.addFilterCondition( const FilterCondition.isNull(property: r'logoUri'), @@ -611,7 +619,8 @@ extension SplTokenQueryFilter }); } - QueryBuilder logoUriIsNotNull() { + QueryBuilder + logoUriIsNotNull() { return QueryBuilder.apply(this, (query) { return query.addFilterCondition( const FilterCondition.isNotNull(property: r'logoUri'), @@ -619,7 +628,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder logoUriEqualTo( + QueryBuilder logoUriEqualTo( String? value, { bool caseSensitive = true, }) { @@ -634,7 +643,8 @@ extension SplTokenQueryFilter }); } - QueryBuilder logoUriGreaterThan( + QueryBuilder + logoUriGreaterThan( String? value, { bool include = false, bool caseSensitive = true, @@ -651,7 +661,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder logoUriLessThan( + QueryBuilder logoUriLessThan( String? value, { bool include = false, bool caseSensitive = true, @@ -668,7 +678,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder logoUriBetween( + QueryBuilder logoUriBetween( String? lower, String? upper, { bool includeLower = true, @@ -689,10 +699,8 @@ extension SplTokenQueryFilter }); } - QueryBuilder logoUriStartsWith( - String value, { - bool caseSensitive = true, - }) { + QueryBuilder + logoUriStartsWith(String value, {bool caseSensitive = true}) { return QueryBuilder.apply(this, (query) { return query.addFilterCondition( FilterCondition.startsWith( @@ -704,7 +712,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder logoUriEndsWith( + QueryBuilder logoUriEndsWith( String value, { bool caseSensitive = true, }) { @@ -719,7 +727,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder logoUriContains( + QueryBuilder logoUriContains( String value, { bool caseSensitive = true, }) { @@ -734,7 +742,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder logoUriMatches( + QueryBuilder logoUriMatches( String pattern, { bool caseSensitive = true, }) { @@ -749,7 +757,8 @@ extension SplTokenQueryFilter }); } - QueryBuilder logoUriIsEmpty() { + QueryBuilder + logoUriIsEmpty() { return QueryBuilder.apply(this, (query) { return query.addFilterCondition( FilterCondition.equalTo(property: r'logoUri', value: ''), @@ -757,7 +766,8 @@ extension SplTokenQueryFilter }); } - QueryBuilder logoUriIsNotEmpty() { + QueryBuilder + logoUriIsNotEmpty() { return QueryBuilder.apply(this, (query) { return query.addFilterCondition( FilterCondition.greaterThan(property: r'logoUri', value: ''), @@ -765,7 +775,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder + QueryBuilder metadataAddressIsNull() { return QueryBuilder.apply(this, (query) { return query.addFilterCondition( @@ -774,7 +784,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder + QueryBuilder metadataAddressIsNotNull() { return QueryBuilder.apply(this, (query) { return query.addFilterCondition( @@ -783,7 +793,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder + QueryBuilder metadataAddressEqualTo(String? value, {bool caseSensitive = true}) { return QueryBuilder.apply(this, (query) { return query.addFilterCondition( @@ -796,7 +806,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder + QueryBuilder metadataAddressGreaterThan( String? value, { bool include = false, @@ -814,7 +824,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder + QueryBuilder metadataAddressLessThan( String? value, { bool include = false, @@ -832,7 +842,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder + QueryBuilder metadataAddressBetween( String? lower, String? upper, { @@ -854,7 +864,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder + QueryBuilder metadataAddressStartsWith(String value, {bool caseSensitive = true}) { return QueryBuilder.apply(this, (query) { return query.addFilterCondition( @@ -867,7 +877,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder + QueryBuilder metadataAddressEndsWith(String value, {bool caseSensitive = true}) { return QueryBuilder.apply(this, (query) { return query.addFilterCondition( @@ -880,7 +890,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder + QueryBuilder metadataAddressContains(String value, {bool caseSensitive = true}) { return QueryBuilder.apply(this, (query) { return query.addFilterCondition( @@ -893,7 +903,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder + QueryBuilder metadataAddressMatches(String pattern, {bool caseSensitive = true}) { return QueryBuilder.apply(this, (query) { return query.addFilterCondition( @@ -906,7 +916,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder + QueryBuilder metadataAddressIsEmpty() { return QueryBuilder.apply(this, (query) { return query.addFilterCondition( @@ -915,7 +925,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder + QueryBuilder metadataAddressIsNotEmpty() { return QueryBuilder.apply(this, (query) { return query.addFilterCondition( @@ -924,7 +934,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder nameEqualTo( + QueryBuilder nameEqualTo( String value, { bool caseSensitive = true, }) { @@ -939,7 +949,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder nameGreaterThan( + QueryBuilder nameGreaterThan( String value, { bool include = false, bool caseSensitive = true, @@ -956,7 +966,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder nameLessThan( + QueryBuilder nameLessThan( String value, { bool include = false, bool caseSensitive = true, @@ -973,7 +983,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder nameBetween( + QueryBuilder nameBetween( String lower, String upper, { bool includeLower = true, @@ -994,7 +1004,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder nameStartsWith( + QueryBuilder nameStartsWith( String value, { bool caseSensitive = true, }) { @@ -1009,7 +1019,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder nameEndsWith( + QueryBuilder nameEndsWith( String value, { bool caseSensitive = true, }) { @@ -1024,7 +1034,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder nameContains( + QueryBuilder nameContains( String value, { bool caseSensitive = true, }) { @@ -1039,7 +1049,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder nameMatches( + QueryBuilder nameMatches( String pattern, { bool caseSensitive = true, }) { @@ -1054,7 +1064,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder nameIsEmpty() { + QueryBuilder nameIsEmpty() { return QueryBuilder.apply(this, (query) { return query.addFilterCondition( FilterCondition.equalTo(property: r'name', value: ''), @@ -1062,7 +1072,8 @@ extension SplTokenQueryFilter }); } - QueryBuilder nameIsNotEmpty() { + QueryBuilder + nameIsNotEmpty() { return QueryBuilder.apply(this, (query) { return query.addFilterCondition( FilterCondition.greaterThan(property: r'name', value: ''), @@ -1070,7 +1081,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder symbolEqualTo( + QueryBuilder symbolEqualTo( String value, { bool caseSensitive = true, }) { @@ -1085,7 +1096,8 @@ extension SplTokenQueryFilter }); } - QueryBuilder symbolGreaterThan( + QueryBuilder + symbolGreaterThan( String value, { bool include = false, bool caseSensitive = true, @@ -1102,7 +1114,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder symbolLessThan( + QueryBuilder symbolLessThan( String value, { bool include = false, bool caseSensitive = true, @@ -1119,7 +1131,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder symbolBetween( + QueryBuilder symbolBetween( String lower, String upper, { bool includeLower = true, @@ -1140,10 +1152,8 @@ extension SplTokenQueryFilter }); } - QueryBuilder symbolStartsWith( - String value, { - bool caseSensitive = true, - }) { + QueryBuilder + symbolStartsWith(String value, {bool caseSensitive = true}) { return QueryBuilder.apply(this, (query) { return query.addFilterCondition( FilterCondition.startsWith( @@ -1155,7 +1165,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder symbolEndsWith( + QueryBuilder symbolEndsWith( String value, { bool caseSensitive = true, }) { @@ -1170,7 +1180,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder symbolContains( + QueryBuilder symbolContains( String value, { bool caseSensitive = true, }) { @@ -1185,7 +1195,7 @@ extension SplTokenQueryFilter }); } - QueryBuilder symbolMatches( + QueryBuilder symbolMatches( String pattern, { bool caseSensitive = true, }) { @@ -1200,7 +1210,8 @@ extension SplTokenQueryFilter }); } - QueryBuilder symbolIsEmpty() { + QueryBuilder + symbolIsEmpty() { return QueryBuilder.apply(this, (query) { return query.addFilterCondition( FilterCondition.equalTo(property: r'symbol', value: ''), @@ -1208,7 +1219,8 @@ extension SplTokenQueryFilter }); } - QueryBuilder symbolIsNotEmpty() { + QueryBuilder + symbolIsNotEmpty() { return QueryBuilder.apply(this, (query) { return query.addFilterCondition( FilterCondition.greaterThan(property: r'symbol', value: ''), @@ -1217,176 +1229,179 @@ extension SplTokenQueryFilter } } -extension SplTokenQueryObject - on QueryBuilder {} +extension SolContractQueryObject + on QueryBuilder {} -extension SplTokenQueryLinks - on QueryBuilder {} +extension SolContractQueryLinks + on QueryBuilder {} -extension SplTokenQuerySortBy on QueryBuilder { - QueryBuilder sortByAddress() { +extension SolContractQuerySortBy + on QueryBuilder { + QueryBuilder sortByAddress() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'address', Sort.asc); }); } - QueryBuilder sortByAddressDesc() { + QueryBuilder sortByAddressDesc() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'address', Sort.desc); }); } - QueryBuilder sortByDecimals() { + QueryBuilder sortByDecimals() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'decimals', Sort.asc); }); } - QueryBuilder sortByDecimalsDesc() { + QueryBuilder sortByDecimalsDesc() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'decimals', Sort.desc); }); } - QueryBuilder sortByLogoUri() { + QueryBuilder sortByLogoUri() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'logoUri', Sort.asc); }); } - QueryBuilder sortByLogoUriDesc() { + QueryBuilder sortByLogoUriDesc() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'logoUri', Sort.desc); }); } - QueryBuilder sortByMetadataAddress() { + QueryBuilder sortByMetadataAddress() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'metadataAddress', Sort.asc); }); } - QueryBuilder sortByMetadataAddressDesc() { + QueryBuilder + sortByMetadataAddressDesc() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'metadataAddress', Sort.desc); }); } - QueryBuilder sortByName() { + QueryBuilder sortByName() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'name', Sort.asc); }); } - QueryBuilder sortByNameDesc() { + QueryBuilder sortByNameDesc() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'name', Sort.desc); }); } - QueryBuilder sortBySymbol() { + QueryBuilder sortBySymbol() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'symbol', Sort.asc); }); } - QueryBuilder sortBySymbolDesc() { + QueryBuilder sortBySymbolDesc() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'symbol', Sort.desc); }); } } -extension SplTokenQuerySortThenBy - on QueryBuilder { - QueryBuilder thenByAddress() { +extension SolContractQuerySortThenBy + on QueryBuilder { + QueryBuilder thenByAddress() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'address', Sort.asc); }); } - QueryBuilder thenByAddressDesc() { + QueryBuilder thenByAddressDesc() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'address', Sort.desc); }); } - QueryBuilder thenByDecimals() { + QueryBuilder thenByDecimals() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'decimals', Sort.asc); }); } - QueryBuilder thenByDecimalsDesc() { + QueryBuilder thenByDecimalsDesc() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'decimals', Sort.desc); }); } - QueryBuilder thenById() { + QueryBuilder thenById() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'id', Sort.asc); }); } - QueryBuilder thenByIdDesc() { + QueryBuilder thenByIdDesc() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'id', Sort.desc); }); } - QueryBuilder thenByLogoUri() { + QueryBuilder thenByLogoUri() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'logoUri', Sort.asc); }); } - QueryBuilder thenByLogoUriDesc() { + QueryBuilder thenByLogoUriDesc() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'logoUri', Sort.desc); }); } - QueryBuilder thenByMetadataAddress() { + QueryBuilder thenByMetadataAddress() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'metadataAddress', Sort.asc); }); } - QueryBuilder thenByMetadataAddressDesc() { + QueryBuilder + thenByMetadataAddressDesc() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'metadataAddress', Sort.desc); }); } - QueryBuilder thenByName() { + QueryBuilder thenByName() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'name', Sort.asc); }); } - QueryBuilder thenByNameDesc() { + QueryBuilder thenByNameDesc() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'name', Sort.desc); }); } - QueryBuilder thenBySymbol() { + QueryBuilder thenBySymbol() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'symbol', Sort.asc); }); } - QueryBuilder thenBySymbolDesc() { + QueryBuilder thenBySymbolDesc() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'symbol', Sort.desc); }); } } -extension SplTokenQueryWhereDistinct - on QueryBuilder { - QueryBuilder distinctByAddress({ +extension SolContractQueryWhereDistinct + on QueryBuilder { + QueryBuilder distinctByAddress({ bool caseSensitive = true, }) { return QueryBuilder.apply(this, (query) { @@ -1394,13 +1409,13 @@ extension SplTokenQueryWhereDistinct }); } - QueryBuilder distinctByDecimals() { + QueryBuilder distinctByDecimals() { return QueryBuilder.apply(this, (query) { return query.addDistinctBy(r'decimals'); }); } - QueryBuilder distinctByLogoUri({ + QueryBuilder distinctByLogoUri({ bool caseSensitive = true, }) { return QueryBuilder.apply(this, (query) { @@ -1408,7 +1423,7 @@ extension SplTokenQueryWhereDistinct }); } - QueryBuilder distinctByMetadataAddress({ + QueryBuilder distinctByMetadataAddress({ bool caseSensitive = true, }) { return QueryBuilder.apply(this, (query) { @@ -1419,7 +1434,7 @@ extension SplTokenQueryWhereDistinct }); } - QueryBuilder distinctByName({ + QueryBuilder distinctByName({ bool caseSensitive = true, }) { return QueryBuilder.apply(this, (query) { @@ -1427,7 +1442,7 @@ extension SplTokenQueryWhereDistinct }); } - QueryBuilder distinctBySymbol({ + QueryBuilder distinctBySymbol({ bool caseSensitive = true, }) { return QueryBuilder.apply(this, (query) { @@ -1436,45 +1451,46 @@ extension SplTokenQueryWhereDistinct } } -extension SplTokenQueryProperty - on QueryBuilder { - QueryBuilder idProperty() { +extension SolContractQueryProperty + on QueryBuilder { + QueryBuilder idProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'id'); }); } - QueryBuilder addressProperty() { + QueryBuilder addressProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'address'); }); } - QueryBuilder decimalsProperty() { + QueryBuilder decimalsProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'decimals'); }); } - QueryBuilder logoUriProperty() { + QueryBuilder logoUriProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'logoUri'); }); } - QueryBuilder metadataAddressProperty() { + QueryBuilder + metadataAddressProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'metadataAddress'); }); } - QueryBuilder nameProperty() { + QueryBuilder nameProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'name'); }); } - QueryBuilder symbolProperty() { + QueryBuilder symbolProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'symbol'); }); 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 index ab232287f..c5f13088b 100644 --- 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 @@ -14,7 +14,7 @@ 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 '../../../models/isar/models/solana/sol_contract.dart'; import '../../../services/solana/solana_token_api.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/text_styles.dart'; @@ -53,7 +53,7 @@ class _AddCustomSolanaTokenViewState bool enableSubFields = false; bool addTokenButtonEnabled = false; - SplToken? currentToken; + SolContract? currentToken; Future _searchTokenMetadata() async { debugPrint('[ADD_CUSTOM_SOLANA_TOKEN] Search button pressed'); @@ -161,7 +161,7 @@ class _AddCustomSolanaTokenViewState ), const SizedBox(height: 8), Text( - "Please enter a valid Solana SPL token mint address " + "Please enter a valid Solana token mint address " "(base58 encoded, ~44 characters).", style: STextStyles.smallMed14(context), ), @@ -204,7 +204,7 @@ class _AddCustomSolanaTokenViewState if (response.value != null && response.value!.isNotEmpty) { final metadata = response.value!; - currentToken = SplToken( + currentToken = SolContract( address: mintController.text.trim(), name: metadata['name'] as String? ?? 'Unknown Token', symbol: metadata['symbol'] as String? ?? '???', @@ -235,7 +235,7 @@ class _AddCustomSolanaTokenViewState // custom values. setState(() { enableSubFields = true; - currentToken = SplToken( + currentToken = SolContract( address: mintController.text.trim(), name: '', symbol: '', @@ -370,7 +370,7 @@ class _AddCustomSolanaTokenViewState controller: mintController, style: STextStyles.field(context), decoration: InputDecoration( - hintText: "SPL token mint address", + hintText: "SOL token mint address", hintStyle: STextStyles.fieldLabel(context), ), ), 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 1460f180f..d81afe51e 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 @@ -17,7 +17,7 @@ import 'package:isar_community/isar.dart'; import '../../../db/isar/main_db.dart'; import '../../../models/isar/models/ethereum/eth_contract.dart'; -import '../../../models/isar/models/solana/spl_token.dart'; +import '../../../models/isar/models/solana/sol_contract.dart'; import '../../../notifications/show_flush_bar.dart'; import '../../../pages_desktop_specific/desktop_home_view.dart'; import '../../../providers/global/price_provider.dart'; @@ -26,7 +26,7 @@ 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/default_sol_tokens.dart'; import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; import '../../../wallets/isar/providers/wallet_info_provider.dart'; @@ -187,7 +187,7 @@ class _EditWalletTokensViewState extends ConsumerState { /// Navigate to add custom Solana token view and handle the result. Future _addCustomSolanaToken() async { - SplToken? token; + SolContract? token; if (isDesktop) { token = await showDialog( @@ -205,11 +205,11 @@ class _EditWalletTokensViewState extends ConsumerState { AddCustomSolanaTokenView.routeName, arguments: widget.walletId, ); - token = result as SplToken?; + token = result as SolContract?; } if (token != null) { - await MainDB.instance.putSplToken(token); + await MainDB.instance.putSolContract(token); // Also add the custom token mint address to the wallet's custom token list. final wallet = ref.read(pWallets).getWallet(widget.walletId); @@ -247,14 +247,14 @@ class _EditWalletTokensViewState extends ConsumerState { if (wallet is SolanaWallet) { final contracts = MainDB.instance - .getSplTokens() + .getSolContracts() .sortByName() .findAllSync(); if (contracts.isEmpty) { - contracts.addAll(DefaultSplTokens.list); + contracts.addAll(DefaultSolTokens.list); MainDB.instance - .putSplTokens(contracts) + .putSolContracts(contracts) .then( (_) => ref.read(priceAnd24hChangeNotifierProvider).updatePrice(), ); 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 4f94e0571..13cb7a022 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 @@ -22,14 +22,14 @@ 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 '../../../models/isar/models/solana/sol_contract.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/default_sol_tokens.dart'; import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; import '../../../wallets/crypto_currency/crypto_currency.dart'; @@ -43,8 +43,8 @@ import '../../../widgets/icon_widgets/x_icon.dart'; 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/add_custom_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'; @@ -133,7 +133,7 @@ class _AddWalletViewState extends ConsumerState { } Future _addSolToken() async { - SplToken? token; + SolContract? token; if (isDesktop) { token = await showDialog( context: context, @@ -151,7 +151,7 @@ class _AddWalletViewState extends ConsumerState { } if (token != null) { - await MainDB.instance.putSplToken(token); + await MainDB.instance.putSolContract(token); if (mounted) { setState(() { if (solTokenEntities @@ -194,14 +194,14 @@ class _AddWalletViewState extends ConsumerState { if (AppConfig.coins.whereType().isNotEmpty) { final contracts = MainDB.instance - .getSplTokens() + .getSolContracts() .sortByName() .findAllSync(); if (contracts.isEmpty) { - contracts.addAll(DefaultSplTokens.list); + contracts.addAll(DefaultSolTokens.list); MainDB.instance - .putSplTokens(contracts) + .putSolContracts(contracts) .then( (value) => ref.read(priceAnd24hChangeNotifierProvider).updatePrice(), diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index ee593154c..91570b63b 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -17,7 +17,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; -import '../../models/isar/models/solana/spl_token.dart'; +import '../../models/isar/models/solana/sol_contract.dart'; import '../../models/isar/models/transaction_note.dart'; import '../../notifications/show_flush_bar.dart'; import '../../pages_desktop_specific/coin_control/desktop_coin_control_use_dialog.dart'; @@ -631,8 +631,8 @@ class _ConfirmTransactionViewState .watch(pCurrentTokenWallet)! .tokenContract : null, - splToken: widget.isTokenTx && wallet is SolanaWallet - ? SplToken( + solContract: widget.isTokenTx && wallet is SolanaWallet + ? SolContract( address: widget.txData.tokenMint ?? "unknown", name: widget.txData.tokenSymbol ?? "Token", symbol: widget.txData.tokenSymbol ?? "TOKEN", @@ -886,8 +886,8 @@ class _ConfirmTransactionViewState )! .tokenContract : null, - splToken: widget.isTokenTx && wallet is SolanaWallet - ? SplToken( + solContract: widget.isTokenTx && wallet is SolanaWallet + ? SolContract( address: widget.txData.tokenMint ?? "unknown", name: widget.txData.tokenSymbol ?? "Token", symbol: widget.txData.tokenSymbol ?? "TOKEN", diff --git a/lib/pages/token_view/solana_token_contract_details_view.dart b/lib/pages/token_view/solana_token_contract_details_view.dart index eadc8c640..2ed51ed1f 100644 --- a/lib/pages/token_view/solana_token_contract_details_view.dart +++ b/lib/pages/token_view/solana_token_contract_details_view.dart @@ -43,11 +43,11 @@ class _SolanaTokenContractDetailsViewState extends ConsumerState { final isDesktop = Util.isDesktop; - late SplToken token; + late SolContract token; @override void initState() { - token = MainDB.instance.isar.splTokens + token = MainDB.instance.isar.solContracts .where() .addressEqualTo(widget.tokenMint) .findFirstSync()!; diff --git a/lib/pages/token_view/sub_widgets/sol_token_select_item.dart b/lib/pages/token_view/sub_widgets/sol_token_select_item.dart index 50a5179e1..556be9f43 100644 --- a/lib/pages/token_view/sub_widgets/sol_token_select_item.dart +++ b/lib/pages/token_view/sub_widgets/sol_token_select_item.dart @@ -12,7 +12,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../models/isar/models/solana/spl_token.dart'; +import '../../../models/isar/models/solana/sol_contract.dart'; import '../../../pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart'; import '../../../providers/providers.dart'; import '../../../themes/stack_colors.dart'; @@ -38,7 +38,7 @@ class SolTokenSelectItem extends ConsumerStatefulWidget { }); final String walletId; - final SplToken token; + final SolContract token; @override ConsumerState createState() => _SolTokenSelectItemState(); diff --git a/lib/pages/token_view/sub_widgets/sol_tokens_list.dart b/lib/pages/token_view/sub_widgets/sol_tokens_list.dart index e2502b791..ec8a0678c 100644 --- a/lib/pages/token_view/sub_widgets/sol_tokens_list.dart +++ b/lib/pages/token_view/sub_widgets/sol_tokens_list.dart @@ -11,7 +11,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:isar_community/isar.dart'; -import '../../../models/isar/models/solana/spl_token.dart'; +import '../../../models/isar/models/solana/sol_contract.dart'; import '../../../providers/db/main_db_provider.dart'; import '../../../utilities/util.dart'; import 'sol_token_select_item.dart'; @@ -28,7 +28,7 @@ class SolanaTokensList extends StatelessWidget { final String searchTerm; final List tokenMints; - List _filter(String searchTerm, List allTokens) { + List _filter(String searchTerm, List allTokens) { if (tokenMints.isEmpty) { return []; } @@ -63,7 +63,7 @@ class SolanaTokensList extends StatelessWidget { // Get all available SOL tokens. final db = ref.watch(mainDBProvider); - final allTokens = db.getSplTokens().findAllSync(); + final allTokens = db.getSolContracts().findAllSync(); final tokens = _filter(searchTerm, allTokens); diff --git a/lib/pages/wallets_view/wallets_overview.dart b/lib/pages/wallets_view/wallets_overview.dart index 675838d4c..29aaa3b02 100644 --- a/lib/pages/wallets_view/wallets_overview.dart +++ b/lib/pages/wallets_view/wallets_overview.dart @@ -164,7 +164,9 @@ class _EthWalletsOverviewState extends ConsumerState { // fetch each token for (final tokenAddress in tokenMintAddresses) { - final token = ref.read(mainDBProvider).getSplTokenSync(tokenAddress); + final token = ref + .read(mainDBProvider) + .getSolContractSync(tokenAddress); // add it to list if it exists in DB if (token != null) { diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_summary.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_summary.dart index b538eb474..c89a57dfb 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_summary.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_summary.dart @@ -188,7 +188,7 @@ class _WDesktopWalletSummaryState extends ConsumerState { .format( balanceToShow, ethContract: tokenContract, - splToken: solanaTokenWallet?.splToken, + solContract: solanaTokenWallet?.solContract, ), style: STextStyles.desktopH3(context), ), diff --git a/lib/services/price_service.dart b/lib/services/price_service.dart index d10ebe973..4cbf27d86 100644 --- a/lib/services/price_service.dart +++ b/lib/services/price_service.dart @@ -26,7 +26,7 @@ class PriceService extends ChangeNotifier { (await MainDB.instance.getEthContracts().addressProperty().findAll()) .toSet(); Future> get solTokenContractAddressesToCheck async => - (await MainDB.instance.getSplTokens().addressProperty().findAll()) + (await MainDB.instance.getSolContracts().addressProperty().findAll()) .toSet(); final Duration updateInterval = const Duration(seconds: 60); diff --git a/lib/utilities/amount/amount_formatter.dart b/lib/utilities/amount/amount_formatter.dart index ead3c6267..f5e047f1e 100644 --- a/lib/utilities/amount/amount_formatter.dart +++ b/lib/utilities/amount/amount_formatter.dart @@ -1,6 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../models/isar/models/ethereum/eth_contract.dart'; -import '../../models/isar/models/solana/spl_token.dart'; +import '../../models/isar/models/solana/sol_contract.dart'; import '../../providers/global/locale_provider.dart'; import '../../providers/global/prefs_provider.dart'; import 'amount.dart'; @@ -53,7 +53,7 @@ class AmountFormatter { Amount amount, { String? overrideUnit, EthContract? ethContract, - SplToken? splToken, + SolContract? solContract, bool withUnitName = true, bool indicatePrecisionLoss = true, }) { @@ -66,7 +66,7 @@ class AmountFormatter { indicatePrecisionLoss: indicatePrecisionLoss, overrideUnit: overrideUnit, tokenContract: ethContract, - splToken: splToken, + splToken: solContract, ); } diff --git a/lib/utilities/amount/amount_unit.dart b/lib/utilities/amount/amount_unit.dart index 2b320abc1..7763a3e6f 100644 --- a/lib/utilities/amount/amount_unit.dart +++ b/lib/utilities/amount/amount_unit.dart @@ -12,7 +12,7 @@ import 'dart:math' as math; import 'package:decimal/decimal.dart'; import '../../models/isar/models/ethereum/eth_contract.dart'; -import '../../models/isar/models/solana/spl_token.dart'; +import '../../models/isar/models/solana/sol_contract.dart'; import 'amount.dart'; import '../util.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; @@ -176,7 +176,7 @@ extension AmountUnitExt on AmountUnit { } } - String unitForSplToken(SplToken token) { + String unitForSplToken(SolContract token) { switch (this) { case AmountUnit.normal: return token.symbol; @@ -253,7 +253,7 @@ extension AmountUnitExt on AmountUnit { bool indicatePrecisionLoss = true, String? overrideUnit, EthContract? tokenContract, - SplToken? splToken, + SolContract? splToken, }) { assert(maxDecimalPlaces >= 0); diff --git a/lib/utilities/default_sol_tokens.dart b/lib/utilities/default_sol_tokens.dart new file mode 100644 index 000000000..fbc69a7ac --- /dev/null +++ b/lib/utilities/default_sol_tokens.dart @@ -0,0 +1,55 @@ +/* + * 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/sol_contract.dart'; + +abstract class DefaultSolTokens { + static List list = [ + SolContract( + address: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + name: "USD Coin", + symbol: "USDC", + decimals: 6, + logoUri: + "https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v/logo.png", + ), + SolContract( + address: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenEst", + name: "Tether", + symbol: "USDT", + decimals: 6, + logoUri: + "https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenEst/logo.svg", + ), + SolContract( + address: "MangoCzJ36AjZyKwVj3VnYU4GTonjfVEnJmvvWaxLac", + name: "Mango", + symbol: "MNGO", + decimals: 6, + logoUri: + "https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/MangoCzJ36AjZyKwVj3VnYU4GTonjfVEnJmvvWaxLac/logo.png", + ), + SolContract( + address: "SRMuApVgqbCmmp3uVrwpad5p4stLBUq3nSoSnqQQXmk", + name: "Serum", + symbol: "SRM", + decimals: 6, + logoUri: + "https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/SRMuApVgqbCmmp3uVrwpad5p4stLBUq3nSoSnqQQXmk/logo.png", + ), + SolContract( + address: "orca8TvxvggsCKvVPXSHXDvKgJ3bNroWusDawg461mpD", + name: "Orca", + symbol: "ORCA", + decimals: 6, + logoUri: + "https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/orcaEKTdK7LKz57chYcSKdBI6qrE5dS1zG4FqHWGcKc/logo.svg", + ), + ]; +} diff --git a/lib/utilities/default_spl_tokens.dart b/lib/utilities/default_spl_tokens.dart deleted file mode 100644 index 603ee9ccf..000000000 --- a/lib/utilities/default_spl_tokens.dart +++ /dev/null @@ -1,50 +0,0 @@ -/* - * 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 list = [ - SplToken( - address: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", - name: "USD Coin", - symbol: "USDC", - decimals: 6, - logoUri: "https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v/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", - ), - ]; -} diff --git a/lib/wallets/isar/models/wallet_solana_token_info.dart b/lib/wallets/isar/models/wallet_solana_token_info.dart index 16d45b168..473db7411 100644 --- a/lib/wallets/isar/models/wallet_solana_token_info.dart +++ b/lib/wallets/isar/models/wallet_solana_token_info.dart @@ -41,8 +41,8 @@ class WalletSolanaTokenInfo implements IsarId { this.cachedBalanceJsonString, }); - SplToken getToken(Isar isar) => - isar.splTokens.where().addressEqualTo(tokenAddress).findFirstSync()!; + SolContract getToken(Isar isar) => + isar.solContracts.where().addressEqualTo(tokenAddress).findFirstSync()!; // Token balance cache. Balance getCachedBalance() { diff --git a/lib/wallets/isar/providers/solana/sol_token_balance_provider.dart b/lib/wallets/isar/providers/solana/sol_token_balance_provider.dart index 831c50836..739e23159 100644 --- a/lib/wallets/isar/providers/solana/sol_token_balance_provider.dart +++ b/lib/wallets/isar/providers/solana/sol_token_balance_provider.dart @@ -39,13 +39,13 @@ final _wstwiProvider = ChangeNotifierProvider.family< ); // Create initial entry if not found. - final splToken = - isar.splTokens.getByAddressSync(data.tokenMint); + final solContract = + isar.solContracts.getByAddressSync(data.tokenMint); initial = WalletSolanaTokenInfo( walletId: data.walletId, tokenAddress: data.tokenMint, - tokenFractionDigits: splToken?.decimals ?? 6, + tokenFractionDigits: solContract?.decimals ?? 6, ); isar.writeTxnSync(() => isar.walletSolanaTokenInfo.putSync(initial!)); diff --git a/lib/wallets/isar/providers/solana/sol_tokens_provider.dart b/lib/wallets/isar/providers/solana/sol_tokens_provider.dart index 1396a9110..fe6a711bb 100644 --- a/lib/wallets/isar/providers/solana/sol_tokens_provider.dart +++ b/lib/wallets/isar/providers/solana/sol_tokens_provider.dart @@ -11,7 +11,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; /// Provides a list of Solana token mint addresses for a specific wallet. /// -/// This provider returns the list of Solana SPL token mint addresses +/// This provider returns the list of Solana token mint addresses /// that the wallet has selected. Token details are not currently persisted /// in the database - only the mint addresses are stored in WalletInfo's otherData. /// diff --git a/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart b/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart index 6c9d913c2..d03b0fd52 100644 --- a/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart +++ b/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart @@ -31,17 +31,17 @@ class SolanaTokenWallet extends Wallet { @override int get isarTransactionVersion => 2; - SolanaTokenWallet(this.parentSolanaWallet, this.splToken) + SolanaTokenWallet(this.parentSolanaWallet, this.solContract) : super(parentSolanaWallet.cryptoCurrency); final SolanaWallet parentSolanaWallet; - final SplToken splToken; + final SolContract solContract; - String get tokenMint => splToken.address; - String get tokenName => splToken.name; - String get tokenSymbol => splToken.symbol; - int get tokenDecimals => splToken.decimals; + String get tokenMint => solContract.address; + String get tokenName => solContract.name; + String get tokenSymbol => solContract.symbol; + int get tokenDecimals => solContract.decimals; @override String get walletId => parentSolanaWallet.walletId; @@ -77,7 +77,7 @@ class SolanaTokenWallet extends Wallet { if (txData.recipients!.length != 1) { throw ArgumentError( - "SPL token transfers support only 1 recipient per transaction", + "SOL token transfers support only 1 recipient per transaction", ); } diff --git a/lib/widgets/icon_widgets/sol_token_icon.dart b/lib/widgets/icon_widgets/sol_token_icon.dart index dc01d1789..8907651c3 100644 --- a/lib/widgets/icon_widgets/sol_token_icon.dart +++ b/lib/widgets/icon_widgets/sol_token_icon.dart @@ -22,14 +22,14 @@ import '../../utilities/logger.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; import '../loading_indicator.dart'; -/// Token icon widget for Solana SPL tokens. +/// Token icon widget for Solana tokens. /// /// Displays the token icon by attempting to fetch from exchange data service. /// Falls back to generic Solana token icon if no icon is found. class SolTokenIcon extends ConsumerStatefulWidget { const SolTokenIcon({super.key, required this.mintAddress, this.size = 22}); - /// The SPL token mint address. + /// The SOL token mint address. final String mintAddress; final double size; diff --git a/lib/widgets/wallet_card.dart b/lib/widgets/wallet_card.dart index 11fec0932..8c6ab834c 100644 --- a/lib/widgets/wallet_card.dart +++ b/lib/widgets/wallet_card.dart @@ -14,7 +14,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../models/isar/models/ethereum/eth_contract.dart'; -import '../models/isar/models/solana/spl_token.dart'; +import '../models/isar/models/solana/sol_contract.dart'; import '../pages/token_view/sol_token_view.dart'; import '../pages/token_view/token_view.dart'; import '../pages/wallet_view/wallet_view.dart'; @@ -102,7 +102,7 @@ class SimpleWalletCard extends ConsumerWidget { BuildContext context, WidgetRef ref, Wallet wallet, - SplToken token, + SolContract token, ) async { final old = ref.read(solanaTokenServiceStateProvider); // exit previous if there is one @@ -193,7 +193,9 @@ class SimpleWalletCard extends ConsumerWidget { if (contractAddress != null) { if (wallet.cryptoCurrency is Solana) { // Handle Solana token. - final token = ref.read(mainDBProvider).getSplTokenSync(contractAddress!); + final token = ref + .read(mainDBProvider) + .getSolContractSync(contractAddress!); if (token == null) { Logging.instance.e( diff --git a/lib/widgets/wallet_info_row/sub_widgets/wallet_info_row_balance.dart b/lib/widgets/wallet_info_row/sub_widgets/wallet_info_row_balance.dart index e8c1c59c2..096428c47 100644 --- a/lib/widgets/wallet_info_row/sub_widgets/wallet_info_row_balance.dart +++ b/lib/widgets/wallet_info_row/sub_widgets/wallet_info_row_balance.dart @@ -12,7 +12,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../db/isar/main_db.dart'; import '../../../models/isar/models/ethereum/eth_contract.dart'; -import '../../../models/isar/models/solana/spl_token.dart'; +import '../../../models/isar/models/solana/sol_contract.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/amount/amount.dart'; import '../../../utilities/amount/amount_formatter.dart'; @@ -39,7 +39,7 @@ class WalletInfoRowBalance extends ConsumerWidget { final Amount totalBalance; EthContract? contract; - SplToken? splToken; + SolContract? splToken; if (contractAddress == null) { totalBalance = info.cachedBalance.total + @@ -51,7 +51,7 @@ class WalletInfoRowBalance extends ConsumerWidget { } else { // Check if it's a Solana wallet. if (info.coin is Solana) { - splToken = MainDB.instance.getSplTokenSync(contractAddress!); + splToken = MainDB.instance.getSolContractSync(contractAddress!); if (splToken != null) { final solanaTokenInfo = ref .watch( diff --git a/lib/widgets/wallet_info_row/wallet_info_row.dart b/lib/widgets/wallet_info_row/wallet_info_row.dart index 5ea2add2b..ba2dfa7c1 100644 --- a/lib/widgets/wallet_info_row/wallet_info_row.dart +++ b/lib/widgets/wallet_info_row/wallet_info_row.dart @@ -13,7 +13,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../models/isar/models/contract.dart'; import '../../models/isar/models/ethereum/eth_contract.dart'; -import '../../models/isar/models/solana/spl_token.dart'; import '../../providers/providers.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/text_styles.dart'; @@ -50,13 +49,13 @@ class WalletInfoRow extends ConsumerWidget { if (contractAddress != null) { if (walletInfo.coin is Solana) { // Solana token. - final splToken = ref.watch( + final solContract = ref.watch( mainDBProvider.select( - (value) => value.getSplTokenSync(contractAddress!), + (value) => value.getSolContractSync(contractAddress!), ), ); - contract = splToken; - contractName = splToken?.name; + contract = solContract; + contractName = solContract?.name; } else { // Ethereum token. final ethContract = ref.watch( diff --git a/test/widget_tests/transaction_card_test.mocks.dart b/test/widget_tests/transaction_card_test.mocks.dart index aa0c16d81..3db3e7075 100644 --- a/test/widget_tests/transaction_card_test.mocks.dart +++ b/test/widget_tests/transaction_card_test.mocks.dart @@ -1682,42 +1682,44 @@ class MockMainDB extends _i1.Mock implements _i3.MainDB { as _i10.Future); @override - _i8.QueryBuilder<_i28.SplToken, _i28.SplToken, _i8.QWhere> getSplTokens() => + _i8.QueryBuilder<_i28.SolContract, _i28.SolContract, _i8.QWhere> + getSolContracts() => (super.noSuchMethod( - Invocation.method(#getSplTokens, []), + Invocation.method(#getSolContracts, []), returnValue: - _FakeQueryBuilder_7<_i28.SplToken, _i28.SplToken, _i8.QWhere>( - this, - Invocation.method(#getSplTokens, []), - ), + _FakeQueryBuilder_7< + _i28.SolContract, + _i28.SolContract, + _i8.QWhere + >(this, Invocation.method(#getSolContracts, [])), ) - as _i8.QueryBuilder<_i28.SplToken, _i28.SplToken, _i8.QWhere>); + as _i8.QueryBuilder<_i28.SolContract, _i28.SolContract, _i8.QWhere>); @override - _i10.Future<_i28.SplToken?> getSplToken(String? tokenMint) => + _i10.Future<_i28.SolContract?> getSolContract(String? tokenMint) => (super.noSuchMethod( - Invocation.method(#getSplToken, [tokenMint]), - returnValue: _i10.Future<_i28.SplToken?>.value(), + Invocation.method(#getSolContract, [tokenMint]), + returnValue: _i10.Future<_i28.SolContract?>.value(), ) - as _i10.Future<_i28.SplToken?>); + as _i10.Future<_i28.SolContract?>); @override - _i28.SplToken? getSplTokenSync(String? tokenMint) => - (super.noSuchMethod(Invocation.method(#getSplTokenSync, [tokenMint])) - as _i28.SplToken?); + _i28.SolContract? getSolContractSync(String? tokenMint) => + (super.noSuchMethod(Invocation.method(#getSolContractSync, [tokenMint])) + as _i28.SolContract?); @override - _i10.Future putSplToken(_i28.SplToken? token) => + _i10.Future putSolContract(_i28.SolContract? token) => (super.noSuchMethod( - Invocation.method(#putSplToken, [token]), + Invocation.method(#putSolContract, [token]), returnValue: _i10.Future.value(0), ) as _i10.Future); @override - _i10.Future putSplTokens(List<_i28.SplToken>? tokens) => + _i10.Future putSolContracts(List<_i28.SolContract>? tokens) => (super.noSuchMethod( - Invocation.method(#putSplTokens, [tokens]), + Invocation.method(#putSolContracts, [tokens]), returnValue: _i10.Future.value(), returnValueForMissingStub: _i10.Future.value(), )