fix(spl): prepare to replace novel token balance provider to be like eth
What changed, and why it matters
This commit changes how Stack Wallet displays Solana token balances. It replaces a live blockchain balance lookup with a temporary placeholder that always reports zero balance. The change is described by the developer as a preparatory step toward a database-backed design similar to Ethereum tokens. Because the balance shown to users will be zero until the follow-up work is completed, users could be misled about their token holdings, and send-flow checks that rely on this provider may behave incorrectly. There is no evidence in the commit that this is being exploited or that it was reported as a security issue.
Treat this as a functional regression with potential security side effects until the follow-up Isar schema commit lands. Verify whether the send-confirmation and send-all flows use this zero provider as the sole source of truth for available balance; if so, users may be unable to send tokens or may see incorrect 'send all' amounts. Review the second commit in the series to confirm that real balances are restored and that the new database-backed provider invalidates stale values correctly. No immediate patch is required if the series is completed promptly, but the temporary state should not be released to production.
Security signals we found
Balance provider downgraded from live RPC fetch to hardcoded zero placeholder
UI send flow now reads zero spendable balance from the provider
Removal of loading/error states for Solana token balance in UI
TODO comments acknowledge temporary zero-balance behavior pending Isar schema work
No input validation or rate-limiting changes observed
Evidence from the diff
The patch refactors pSolanaTokenBalance from an async FutureProvider that queried Solana RPC (via SolanaTokenAPI.getTokenAccountsByOwner / getTokenAccountBalance) into a synchronous Provider that unconditionally returns a zero Balance. Callers in six UI files were updated to remove AsyncValue handling (.when/.whenData) and treat the provider as already-resolved. The provider’s parameter tuple no longer includes fractionDigits, and the TODO comments state the intent to later mirror the Ethereum pattern (database-persisted SolanaTokenWalletInfo). SolanaTokenWallet.updateBalance() still fetches the real on-chain balance but no longer feeds it into the provider; a TODO notes future Isar persistence. The immediate effect is that all Solana token balances rendered through pSolanaTokenBalance are zero, including in the send-all and send-confirmation flows, although the actual transaction-building path may still compute balances independently.
Changed components
lib/wallets/isar/providers/solana/sol_token_balance_provider.dartlib/pages/token_view/sub_widgets/sol_token_select_item.dartlib/pages/token_view/sub_widgets/token_summary_sol.dartlib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dartlib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_summary.dartlib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dartInspect captured patch +106 / −302
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 24d3f10..b7772a6 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<SolTokenSelectItem> {
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 329050a..e8de54b 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<StackColors>()!.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<StackColors>()!.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<StackColors>()!.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<StackColors>()!.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<StackColors>()!.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<StackColors>()!.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<StackColors>()!.tokenSummaryTextSecondary,
- ),
- ),
- ],
+ SvgPicture.asset(
+ Assets.svg.walletDesktop,
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.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<StackColors>()!.tokenSummaryTextPrimary,
+ ).extension<StackColors>()!.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<StackColors>()!.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<StackColors>()!.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<StackColors>()!.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 95fd40d..cc20e75 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<DesktopSolTokenSend> {
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<DesktopSolTokenSend> {
Future<void> 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 688044f..9071a9e 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<DesktopWalletSummary> {
)),
);
} 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 ad36db8..f2c4199 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 7e77780..a66af6d 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(
Why this scored 35/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.