fix(spl): replace novel token balance provider to follow eth's example
What changed, and why it matters
This commit finishes a previously incomplete Solana token-balance feature. Before the change, the app showed a hard-coded zero balance for Solana tokens and could not refresh a token wallet on its own. After the change, real token balances are read from the Solana blockchain, stored in a local database, and displayed in the wallet. The change also makes the refresh button refresh the token balance instead of only the parent Solana wallet. There is no direct evidence in the commit of a security vulnerability, but it replaces a placeholder with real balance handling, which is a functional fix that could affect user funds if it had bugs.
Treat this as a functional correctness fix rather than a security patch. Reviewers should verify that the new `WalletSolanaTokenInfo` model is correctly registered in all Isar schema locations, that balance updates are atomic and handle concurrent refreshes safely, and that the refresh button logic does not skip the parent wallet refresh unexpectedly. No immediate security response is indicated by the diff alone.
Security signals we found
Replaces a hard-coded zero-balance placeholder with real balance persistence and UI display
Adds database schema registration for a new token-balance cache model
Changes refresh behavior to update cached token balances from the RPC network
No explicit security claim, CVE, or attribution in commit message or diff
No input validation, cryptographic, or network-trust changes visible in the diff
Evidence from the diff
The patch adds a new Isar collection WalletSolanaTokenInfo to cache Solana SPL token balances, registers it in the main Isar schema, and replaces the temporary zero-balance provider pSolanaTokenBalance with a database-backed provider that watches WalletSolanaTokenInfo. SolanaTokenWallet now overrides walletId and mainDB to delegate to its parent wallet, persists fetched token balances via updateBalance(), and refresh() now refreshes both the parent wallet and the token balance. The wallet refresh button now checks for a current Solana token wallet before falling back to the parent wallet. The commit removes TODO comments about the missing Isar schema and follows the existing Ethereum token pattern.
Changed components
lib/db/isar/main_db.dartlib/models/isar/models/isar_models.dartlib/pages/wallet_view/sub_widgets/wallet_refresh_button.dartlib/wallets/isar/models/wallet_solana_token_info.dartlib/wallets/isar/providers/solana/sol_token_balance_provider.dartlib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dartInspect captured patch +261 / −49
diff --git a/lib/db/isar/main_db.dart b/lib/db/isar/main_db.dart
index 61b75c9..4245729 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 d164ec6..eb61a82 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 6e98f4a..e75a063 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<WalletRefreshButton> {
splashColor: Theme.of(context).extension<StackColors>()!.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 0000000..a80e989
--- /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<void> 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 f2c4199..831c508 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 a66af6d..2de296b 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<void> 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<void> 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
Why this scored 32/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.