fix: hide dust balance from "confirming" (#3456)
What changed, and why it matters
This commit changes how Cake Wallet's Zcash wallet calculates and displays balances that are still 'confirming' or otherwise unavailable. It filters out tiny 'dust' notes from the unavailable balance, adjusts which note pools are considered spendable during the Zcash Ironwood network upgrade, and changes the bitmask used when building transactions. The main user-visible effect is that small unspendable notes no longer appear as 'confirming' balance, which could previously make users think they had funds that were not actually usable.
Review the new bitmask values (4/8) against the zkool library documentation to ensure they correctly map to Orchard/Ironwood and do not accidentally exclude legitimate spendable funds or include unintended pools. Verify that minSpendableNote and _ironwoodMigrateMinNote match the library's documented minimums. Consider adding unit tests for _sweepableTotal and _migratableOrchardTotal with dust, locked, and out-of-range note scenarios. No immediate security patch is indicated, but the change affects user trust in displayed balances.
Security signals we found
Balance display could previously overstate spendable/unconfirmed funds by including dust notes
Transaction source pool bitmask narrowed (7→4, 15→8), changing which note pools can fund payments
Auto-shield and migration now run before balance is recomputed, so displayed balance reflects post-migration state
New per-note minimum thresholds align auto-shield/migration guards with balance UI
Evidence from the diff
The patch refactors Zcash balance accounting in cw_zcash. It exposes _minSpendableNote as minSpendableNote, adds an Ironwood-specific migration threshold (_ironwoodMigrateMinNote = 500000), and introduces helper methods _sweepableTotal and _migratableOrchardTotal that only count notes above the spendable/migration floors. The balance display logic now uses these filtered totals instead of raw pool balances, so ‘dust’ notes below the threshold are excluded from unavailable/confirming amounts. It also changes the srcPools bitmask from 7/15 to 4/8, restricting payment sources to Orchard/Ironwood respectively, and reorders when auto-shield and Ironwood migration run relative to balance fetching.
Changed components
cw_zcash/lib/src/zcash_wallet.dartcw_zcash/lib/src/zcash_taddress_rotation.dartZcash balance display ('confirming' / unavailable amounts)Zcash auto-shielding and Ironwood migration logicZcash payment source pool selectionInspect captured patch +70 / −36
diff --git a/cw_zcash/lib/src/zcash_taddress_rotation.dart b/cw_zcash/lib/src/zcash_taddress_rotation.dart
index cbbb0b0e..e0b344e8 100644
--- a/cw_zcash/lib/src/zcash_taddress_rotation.dart
+++ b/cw_zcash/lib/src/zcash_taddress_rotation.dart
@@ -34,7 +34,7 @@ class ZcashTaddressRotation {
static bool _isStarted = false;
static zkool_coin.Coin get c => ZcashWalletBase.c;
static const int _sweepThreshold = 30000;
- static const int _minSpendableNote = 5000;
+ static const int minSpendableNote = 5000;
static const int _lookahead = 5;
// Matches transparentLimit in ZcashWalletBase._oneshotSync.
static const int _transparentSyncLimit = 100;
@@ -415,7 +415,7 @@ class ZcashTaddressRotation {
if (note.pool != NotePool.transparent.index || note.locked) {
continue;
}
- if (note.value < BigInt.from(_minSpendableNote)) {
+ if (note.value < BigInt.from(minSpendableNote)) {
continue;
}
if (note.height > height) {
diff --git a/cw_zcash/lib/src/zcash_wallet.dart b/cw_zcash/lib/src/zcash_wallet.dart
index cc6df83e..6a09724f 100644
--- a/cw_zcash/lib/src/zcash_wallet.dart
+++ b/cw_zcash/lib/src/zcash_wallet.dart
@@ -83,6 +83,12 @@ abstract class ZcashWalletBase
static const int _autoShieldMinSweep = 30000;
+ // zkool's migrate::MIN_SD.
+ static const int _ironwoodMigrateMinNote = 500000;
+
+ static int _minSweepThreshold({required final bool ironwood}) =>
+ ironwood ? _ironwoodMigrateMinNote : _autoShieldMinSweep;
+
Money _feeFromTxPlan(
final zkool_pay.PcztPackage txPlan,
final TransactionPriority priority,
@@ -469,8 +475,7 @@ abstract class ZcashWalletBase
}
// pools parameter: bitmask for which pools to use for sending
- // 1=Transparent, 2=Sapling, 4=Orchard, 7=All pools
- // Using 7 (all pools) allows spending from any pool type
+ // 1=Transparent, 2=Sapling, 4=Orchard, 8=Ironwood
try {
return await runWithCoin(
accountId: accountId,
@@ -479,7 +484,7 @@ abstract class ZcashWalletBase
final txPlan = await zkool_pay.prepare(
recipients: recipients,
options: zkool_pay.PaymentOptions(
- srcPools: ironwood ? 15 : 7,
+ srcPools: ironwood ? 8 : 4,
recipientPaysFee: receipientPaysFee,
smartTransparent: false,
mode: 0,
@@ -1175,6 +1180,40 @@ abstract class ZcashWalletBase
}
}
+ /// Total of transparent + sapling notes at or above the per-note spendable floor.
+ static Future<BigInt> _sweepableTotal(final zkool_coin.Coin coin) async {
+ final notes = await zkool_account.listNotes(c: coin);
+ BigInt sweepable = BigInt.zero;
+ for (int i = 0; i < notes.length; i++) {
+ final note = notes[i];
+ if (note.pool < 0 || note.pool >= NotePool.values.length) {
+ continue;
+ }
+ final noteType = NotePool.values[note.pool];
+ if ((noteType == NotePool.transparent || noteType == NotePool.sapling) &&
+ note.value >= BigInt.from(ZcashTaddressRotation.minSpendableNote)) {
+ sweepable += note.value;
+ }
+ }
+ return sweepable;
+ }
+
+ /// Orchard notes that migration will actually split or move to Ironwood.
+ static Future<BigInt> _migratableOrchardTotal(final zkool_coin.Coin coin) async {
+ final notes = await zkool_account.listNotes(c: coin);
+ BigInt migratable = BigInt.zero;
+ for (int i = 0; i < notes.length; i++) {
+ final note = notes[i];
+ if (note.pool != NotePool.orchard.index || note.locked) {
+ continue;
+ }
+ if (note.value >= BigInt.from(_ironwoodMigrateMinNote)) {
+ migratable += note.value;
+ }
+ }
+ return migratable;
+ }
+
Future<void> _$autoShield() async {
if (syncStatus is! SyncedSyncStatus) {
return;
@@ -1182,24 +1221,12 @@ abstract class ZcashWalletBase
final txId = await runWithCoin(
accountId: accountId,
func: (coin) async {
- final _notes = await zkool_account.listNotes(c: coin);
- BigInt sweepable = BigInt.zero;
- for (int i = 0; i < _notes.length; i++) {
- final note = _notes[i];
- if (note.pool < 0 || note.pool >= NotePool.values.length) {
- continue;
- }
- final noteType = NotePool.values[note.pool];
- if (noteType == NotePool.transparent || noteType == NotePool.sapling) {
- sweepable += note.value;
- }
- }
+ final sweepable = await _sweepableTotal(coin);
+ final ironwood = await zkool_network.isIronwoodActive(c: coin);
- if (sweepable <= BigInt.from(_autoShieldMinSweep)) {
+ if (sweepable <= BigInt.from(_minSweepThreshold(ironwood: ironwood))) {
return null;
}
-
- final ironwood = await zkool_network.isIronwoodActive(c: coin);
final txPlan = await zkool_pay.prepare(
recipients: [
zkool_paydart.Recipient(
@@ -1332,17 +1359,6 @@ abstract class ZcashWalletBase
}) async {
try {
await _updateIronwoodActive();
- final bal = await runWithCoin(
- accountId: accountId,
- func: (final coin) async => zkool_sync.balance(c: coin),
- );
-
- // 0 - transparent, 1 - sapling, 2 - orchard, 3 - ironwood
- final transparent = bal.field0[0];
- final sapling = bal.field0.length > 1 ? bal.field0[1] : BigInt.zero;
- final orchard = bal.field0.length > 2 ? bal.field0[2] : BigInt.zero;
- final ironwood = bal.field0.length > 3 ? bal.field0[3] : BigInt.zero;
-
if (runAutoShield) {
await _autoShield();
}
@@ -1350,18 +1366,36 @@ abstract class ZcashWalletBase
await _ironwoodMigrate();
}
+ final (bal, sweepable, migratableOrchard) = await runWithCoin(
+ accountId: accountId,
+ func: (final coin) async => (
+ await zkool_sync.balance(c: coin),
+ await _sweepableTotal(coin),
+ await _migratableOrchardTotal(coin),
+ ),
+ );
+
+ // 0 - transparent, 1 - sapling, 2 - orchard, 3 - ironwood
+ final orchard = bal.field0.length > 2 ? bal.field0[2] : BigInt.zero;
+ final ironwood = bal.field0.length > 3 ? bal.field0[3] : BigInt.zero;
+
// After NU6.3, Orchard notes are migrated to Ironwood - show them as unconfirmed.
+ // Unavailable uses the same per-note totals and thresholds as auto-shield/migration guards.
final BigInt availableAmount;
final BigInt unavailableAmount;
if (ironwoodActive == true && orchard > BigInt.zero) {
- availableAmount = sapling + ironwood;
- unavailableAmount = transparent + orchard;
+ final sweepableUnavailable = sweepable <= BigInt.from(_ironwoodMigrateMinNote)
+ ? BigInt.zero
+ : sweepable;
+ availableAmount = ironwood;
+ unavailableAmount = migratableOrchard + sweepableUnavailable;
} else {
- availableAmount = sapling + orchard + ironwood;
- unavailableAmount = transparent;
+ final minSweep = _minSweepThreshold(ironwood: ironwoodActive == true);
+ availableAmount = orchard + ironwood;
+ unavailableAmount = sweepable <= BigInt.from(minSweep) ? BigInt.zero : sweepable;
}
- balance[CryptoCurrency.zec] = ZcashBalance(
+ balance[currency] = ZcashBalance(
Money(availableAmount, currency),
Money(unavailableAmount, currency),
frozen: Money.zero(currency),
Why this scored 26/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.