resolve private balance refresh issues and spark address generation
What changed, and why it matters
This commit fixes bugs in how Stack Wallet refreshes private Spark (privacy coin) balances and generates new Spark addresses. The changes prevent the wallet from skipping coins when its local cache gets out of sync, avoid duplicate database entries, and ensure new Spark addresses are derived from the correct prior address. These are reliability/correctness fixes that could affect whether a user sees their full balance or receives funds to a valid address, but they do not appear to introduce a remote exploit path.
Treat this as a bug-fix commit with privacy/financial-reliability implications. Users relying on Spark balances should update and, if they previously saw missing balances or address-generation issues, reset the Spark electrumx cache from the UI after updating. No immediate incident response is indicated from the diff alone.
Security signals we found
Privacy-balance correctness: cache size mismatch could previously cause incomplete anonymity-set fetches, potentially hiding spendable coins or showing stale balances.
Database integrity: duplicate inserts and reliance on lastInsertRowId after possible no-op inserts could corrupt SparkCoin/SparkSetCoins relationships.
Address derivation bug: generating the next Spark address from the wrong source address could lead to address reuse or diversifier collisions.
UI state consistency: clearing the Spark cache now also resets the cached block-hash map, ensuring a true full refresh.
Evidence from the diff
The patch addresses three areas in Firo Spark handling: (1) cache coordinator now detects and recovers from a local cache size that is larger than the server’s reported anonymity set size, computes fetch ranges from the correct previous size, and avoids negative/zero fetch counts; (2) cache writer switches to INSERT OR IGNORE and explicitly queries the existing coin id before linking, preventing duplicate SparkCoin/SparkSetCoins rows and foreign-key/linking errors; (3) Spark address generation now uses getCurrentReceivingSparkAddress() instead of the non-Spark receiving address, fixing diversifier derivation. A UI cache-reset button is also wired to clear the block-hash cache so the reset actually forces a full refetch.
Changed components
lib/db/sqlite/firo_cache_coordinator.dartlib/db/sqlite/firo_cache_writer.dartlib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dartlib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/more_features/more_features_dialog.dartInspect captured patch +64 / −14
diff --git a/lib/db/sqlite/firo_cache_coordinator.dart b/lib/db/sqlite/firo_cache_coordinator.dart
index 5ecd753..3fff06f 100644
--- a/lib/db/sqlite/firo_cache_coordinator.dart
+++ b/lib/db/sqlite/firo_cache_coordinator.dart
@@ -109,7 +109,23 @@ abstract class FiroCacheCoordinator {
return;
}
- final numberOfCoinsToFetch = meta.size - prevSize;
+ final int effectivePrevSize;
+ if (prevSize > meta.size) {
+ Logging.instance.w(
+ "Spark cache size mismatch for groupId=$groupId: "
+ "prevSize=$prevSize > meta.size=${meta.size}. "
+ "Falling back to full refetch for this set.",
+ );
+ effectivePrevSize = 0;
+ } else {
+ effectivePrevSize = prevSize;
+ }
+
+ final numberOfCoinsToFetch = meta.size - effectivePrevSize;
+ if (numberOfCoinsToFetch <= 0) {
+ // Already up to date for this block hash/set hash.
+ return;
+ }
final fullSectorCount = numberOfCoinsToFetch ~/ sectorSize;
final remainder = numberOfCoinsToFetch % sectorSize;
@@ -117,14 +133,14 @@ abstract class FiroCacheCoordinator {
final List<dynamic> coins = [];
for (int i = 0; i < fullSectorCount; i++) {
- final start = (i * sectorSize);
+ final start = effectivePrevSize + (i * sectorSize);
final data = await client.getSparkAnonymitySetBySector(
coinGroupId: groupId,
latestBlock: meta.blockHash,
startIndex: start,
endIndex: start + sectorSize,
);
- progressUpdated?.call(start + sectorSize, numberOfCoinsToFetch);
+ progressUpdated?.call(((i + 1) * sectorSize), numberOfCoinsToFetch);
coins.addAll(data);
}
@@ -133,8 +149,8 @@ abstract class FiroCacheCoordinator {
final data = await client.getSparkAnonymitySetBySector(
coinGroupId: groupId,
latestBlock: meta.blockHash,
- startIndex: numberOfCoinsToFetch - remainder,
- endIndex: numberOfCoinsToFetch,
+ startIndex: effectivePrevSize + numberOfCoinsToFetch - remainder,
+ endIndex: effectivePrevSize + numberOfCoinsToFetch,
);
progressUpdated?.call(numberOfCoinsToFetch, numberOfCoinsToFetch);
diff --git a/lib/db/sqlite/firo_cache_writer.dart b/lib/db/sqlite/firo_cache_writer.dart
index fadc3eb..3bfa702 100644
--- a/lib/db/sqlite/firo_cache_writer.dart
+++ b/lib/db/sqlite/firo_cache_writer.dart
@@ -90,21 +90,46 @@ FCResult _updateSparkAnonSetCoinsWith(
for (final coin in coins) {
db.execute(
"""
- INSERT INTO SparkCoin (serialized, txHash, context, groupId)
+ INSERT OR IGNORE INTO SparkCoin (serialized, txHash, context, groupId)
VALUES (?, ?, ?, ?);
""",
[coin.serialized, coin.txHash, coin.context, coin.groupId],
);
- final coinId = db.lastInsertRowId;
+ final coinIdResult = db.select(
+ """
+ SELECT id
+ FROM SparkCoin
+ WHERE serialized = ? AND txHash = ? AND context = ? AND groupId = ?
+ LIMIT 1;
+ """,
+ [coin.serialized, coin.txHash, coin.context, coin.groupId],
+ );
+ if (coinIdResult.isEmpty) {
+ throw Exception(
+ "Failed to resolve SparkCoin id after insert/ignore operation",
+ );
+ }
+ final coinId = coinIdResult.first["id"] as int;
// finally add the row id to the newly added set
- db.execute(
+ final hasSetCoin = db.select(
"""
- INSERT INTO SparkSetCoins (setId, coinId)
- VALUES (?, ?);
+ SELECT 1
+ FROM SparkSetCoins
+ WHERE setId = ? AND coinId = ?
+ LIMIT 1;
""",
[setId, coinId],
);
+ if (hasSetCoin.isEmpty) {
+ db.execute(
+ """
+ INSERT INTO SparkSetCoins (setId, coinId)
+ VALUES (?, ?);
+ """,
+ [setId, coinId],
+ );
+ }
}
db.execute("COMMIT;");
diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/more_features/more_features_dialog.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/more_features/more_features_dialog.dart
index 57c5a11..a7971f7 100644
--- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/more_features/more_features_dialog.dart
+++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/more_features/more_features_dialog.dart
@@ -378,6 +378,7 @@ class _MoreFeaturesDialogState extends ConsumerState<MoreFeaturesDialog> {
case WalletFeature.clearSparkCache:
return _MoreFeaturesClearSparkCacheItem(
+ walletId: widget.walletId,
cryptoCurrency: wallet.cryptoCurrency,
);
@@ -654,21 +655,23 @@ class _MoreFeaturesItemBase extends StatelessWidget {
}
}
-class _MoreFeaturesClearSparkCacheItem extends StatefulWidget {
+class _MoreFeaturesClearSparkCacheItem extends ConsumerStatefulWidget {
const _MoreFeaturesClearSparkCacheItem({
super.key,
+ required this.walletId,
required this.cryptoCurrency,
});
+ final String walletId;
final CryptoCurrency cryptoCurrency;
@override
- State<_MoreFeaturesClearSparkCacheItem> createState() =>
+ ConsumerState<_MoreFeaturesClearSparkCacheItem> createState() =>
_MoreFeaturesClearSparkCacheItemState();
}
class _MoreFeaturesClearSparkCacheItemState
- extends State<_MoreFeaturesClearSparkCacheItem> {
+ extends ConsumerState<_MoreFeaturesClearSparkCacheItem> {
bool _onPressedLock = false;
static const label = "Reset Spark electrumx cache";
@@ -685,6 +688,12 @@ class _MoreFeaturesClearSparkCacheItemState
await FiroCacheCoordinator.clearSharedCache(
widget.cryptoCurrency.network,
);
+ await ref.read(pWalletInfo(widget.walletId)).updateOtherData(
+ newEntries: {
+ WalletInfoKeys.firoSparkCacheSetBlockHashCache: <String, String>{},
+ },
+ isar: ref.read(mainDBProvider).isar,
+ );
setState(() {
// trigger rebuild for cache size display
});
diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart
index 0dec8aa..80e19de 100644
--- a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart
+++ b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart
@@ -394,7 +394,7 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
Future<Address> generateNextSparkAddress({required bool saveToDB}) async {
final currentDiversifier =
- (await getCurrentReceivingAddress())?.derivationIndex;
+ (await getCurrentReceivingSparkAddress())?.derivationIndex;
// if current is null, start at index 1
int diversifier = (currentDiversifier ?? 0) + 1;
if (diversifier == libSpark.sparkChange) {
Why this scored 42/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.