fix: hide dust balance from "confirming" (#3456)

cyan committed Aug 4, 2026 at 01:09 UTC dc2134172590fcb04ac43db3db0df4d500b1784b
2 files changed +70 -36
cw_zcash/lib/src/zcash_taddress_rotation.dart
+2 -2
@@ -34,7 +34,7 @@ class ZcashTaddressRotation {
34 static bool _isStarted = false;
35 static zkool_coin.Coin get c => ZcashWalletBase.c;
36 static const int _sweepThreshold = 30000;
37 - static const int _minSpendableNote = 5000;
37 + static const int minSpendableNote = 5000;
38 static const int _lookahead = 5;
39 // Matches transparentLimit in ZcashWalletBase._oneshotSync.
40 static const int _transparentSyncLimit = 100;
@@ -415,7 +415,7 @@ class ZcashTaddressRotation {
415 if (note.pool != NotePool.transparent.index || note.locked) {
416 continue;
417 }
418 - if (note.value < BigInt.from(_minSpendableNote)) {
418 + if (note.value < BigInt.from(minSpendableNote)) {
419 continue;
420 }
421 if (note.height > height) {
cw_zcash/lib/src/zcash_wallet.dart
+68 -34
@@ -83,6 +83,12 @@ abstract class ZcashWalletBase
83
84 static const int _autoShieldMinSweep = 30000;
85
86 + // zkool's migrate::MIN_SD.
87 + static const int _ironwoodMigrateMinNote = 500000;
88 +
89 + static int _minSweepThreshold({required final bool ironwood}) =>
90 + ironwood ? _ironwoodMigrateMinNote : _autoShieldMinSweep;
91 +
92 Money _feeFromTxPlan(
93 final zkool_pay.PcztPackage txPlan,
94 final TransactionPriority priority,
@@ -469,8 +475,7 @@ abstract class ZcashWalletBase
475 }
476
477 // pools parameter: bitmask for which pools to use for sending
472 - // 1=Transparent, 2=Sapling, 4=Orchard, 7=All pools
473 - // Using 7 (all pools) allows spending from any pool type
478 + // 1=Transparent, 2=Sapling, 4=Orchard, 8=Ironwood
479 try {
480 return await runWithCoin(
481 accountId: accountId,
@@ -479,7 +484,7 @@ abstract class ZcashWalletBase
484 final txPlan = await zkool_pay.prepare(
485 recipients: recipients,
486 options: zkool_pay.PaymentOptions(
482 - srcPools: ironwood ? 15 : 7,
487 + srcPools: ironwood ? 8 : 4,
488 recipientPaysFee: receipientPaysFee,
489 smartTransparent: false,
490 mode: 0,
@@ -1175,6 +1180,40 @@ abstract class ZcashWalletBase
1180 }
1181 }
1182
1183 + /// Total of transparent + sapling notes at or above the per-note spendable floor.
1184 + static Future<BigInt> _sweepableTotal(final zkool_coin.Coin coin) async {
1185 + final notes = await zkool_account.listNotes(c: coin);
1186 + BigInt sweepable = BigInt.zero;
1187 + for (int i = 0; i < notes.length; i++) {
1188 + final note = notes[i];
1189 + if (note.pool < 0 || note.pool >= NotePool.values.length) {
1190 + continue;
1191 + }
1192 + final noteType = NotePool.values[note.pool];
1193 + if ((noteType == NotePool.transparent || noteType == NotePool.sapling) &&
1194 + note.value >= BigInt.from(ZcashTaddressRotation.minSpendableNote)) {
1195 + sweepable += note.value;
1196 + }
1197 + }
1198 + return sweepable;
1199 + }
1200 +
1201 + /// Orchard notes that migration will actually split or move to Ironwood.
1202 + static Future<BigInt> _migratableOrchardTotal(final zkool_coin.Coin coin) async {
1203 + final notes = await zkool_account.listNotes(c: coin);
1204 + BigInt migratable = BigInt.zero;
1205 + for (int i = 0; i < notes.length; i++) {
1206 + final note = notes[i];
1207 + if (note.pool != NotePool.orchard.index || note.locked) {
1208 + continue;
1209 + }
1210 + if (note.value >= BigInt.from(_ironwoodMigrateMinNote)) {
1211 + migratable += note.value;
1212 + }
1213 + }
1214 + return migratable;
1215 + }
1216 +
1217 Future<void> _$autoShield() async {
1218 if (syncStatus is! SyncedSyncStatus) {
1219 return;
@@ -1182,24 +1221,12 @@ abstract class ZcashWalletBase
1221 final txId = await runWithCoin(
1222 accountId: accountId,
1223 func: (coin) async {
1185 - final _notes = await zkool_account.listNotes(c: coin);
1186 - BigInt sweepable = BigInt.zero;
1187 - for (int i = 0; i < _notes.length; i++) {
1188 - final note = _notes[i];
1189 - if (note.pool < 0 || note.pool >= NotePool.values.length) {
1190 - continue;
1191 - }
1192 - final noteType = NotePool.values[note.pool];
1193 - if (noteType == NotePool.transparent || noteType == NotePool.sapling) {
1194 - sweepable += note.value;
1195 - }
1196 - }
1224 + final sweepable = await _sweepableTotal(coin);
1225 + final ironwood = await zkool_network.isIronwoodActive(c: coin);
1226
1198 - if (sweepable <= BigInt.from(_autoShieldMinSweep)) {
1227 + if (sweepable <= BigInt.from(_minSweepThreshold(ironwood: ironwood))) {
1228 return null;
1229 }
1201 -
1202 - final ironwood = await zkool_network.isIronwoodActive(c: coin);
1230 final txPlan = await zkool_pay.prepare(
1231 recipients: [
1232 zkool_paydart.Recipient(
@@ -1332,17 +1359,6 @@ abstract class ZcashWalletBase
1359 }) async {
1360 try {
1361 await _updateIronwoodActive();
1335 - final bal = await runWithCoin(
1336 - accountId: accountId,
1337 - func: (final coin) async => zkool_sync.balance(c: coin),
1338 - );
1339 -
1340 - // 0 - transparent, 1 - sapling, 2 - orchard, 3 - ironwood
1341 - final transparent = bal.field0[0];
1342 - final sapling = bal.field0.length > 1 ? bal.field0[1] : BigInt.zero;
1343 - final orchard = bal.field0.length > 2 ? bal.field0[2] : BigInt.zero;
1344 - final ironwood = bal.field0.length > 3 ? bal.field0[3] : BigInt.zero;
1345 -
1362 if (runAutoShield) {
1363 await _autoShield();
1364 }
@@ -1350,18 +1366,36 @@ abstract class ZcashWalletBase
1366 await _ironwoodMigrate();
1367 }
1368
1369 + final (bal, sweepable, migratableOrchard) = await runWithCoin(
1370 + accountId: accountId,
1371 + func: (final coin) async => (
1372 + await zkool_sync.balance(c: coin),
1373 + await _sweepableTotal(coin),
1374 + await _migratableOrchardTotal(coin),
1375 + ),
1376 + );
1377 +
1378 + // 0 - transparent, 1 - sapling, 2 - orchard, 3 - ironwood
1379 + final orchard = bal.field0.length > 2 ? bal.field0[2] : BigInt.zero;
1380 + final ironwood = bal.field0.length > 3 ? bal.field0[3] : BigInt.zero;
1381 +
1382 // After NU6.3, Orchard notes are migrated to Ironwood - show them as unconfirmed.
1383 + // Unavailable uses the same per-note totals and thresholds as auto-shield/migration guards.
1384 final BigInt availableAmount;
1385 final BigInt unavailableAmount;
1386 if (ironwoodActive == true && orchard > BigInt.zero) {
1357 - availableAmount = sapling + ironwood;
1358 - unavailableAmount = transparent + orchard;
1387 + final sweepableUnavailable = sweepable <= BigInt.from(_ironwoodMigrateMinNote)
1388 + ? BigInt.zero
1389 + : sweepable;
1390 + availableAmount = ironwood;
1391 + unavailableAmount = migratableOrchard + sweepableUnavailable;
1392 } else {
1360 - availableAmount = sapling + orchard + ironwood;
1361 - unavailableAmount = transparent;
1393 + final minSweep = _minSweepThreshold(ironwood: ironwoodActive == true);
1394 + availableAmount = orchard + ironwood;
1395 + unavailableAmount = sweepable <= BigInt.from(minSweep) ? BigInt.zero : sweepable;
1396 }
1397
1364 - balance[CryptoCurrency.zec] = ZcashBalance(
1398 + balance[currency] = ZcashBalance(
1399 Money(availableAmount, currency),
1400 Money(unavailableAmount, currency),
1401 frozen: Money.zero(currency),