fix: sanitize Electrum fee estimates (#3499)

* fix: sanitize Electrum fee estimates * chore: drop test changes from this PR Removes the test additions/modifications introduced by this branch so the test design is left to the maintainers. Production code is unchanged.

Seth For Privacy committed Aug 16, 2026 at 05:01 UTC 86db89913b8f8dfa94a85be67d53bb2a348835f9
2 files changed +44 -23
cw_bitcoin/lib/electrum.dart
+21 -6
@@ -579,14 +579,29 @@ class ElectrumClient {
579 return [];
580 });
581
582 + // Floor at 0 so unavailable/-1 estimates never become negative rates;
583 + // cap at 2000 sat/vB per CW-1597.
584 + static const int _maxFeeRate = 2000;
585 +
586 + static int _sanitizeFeeRate(double feeRate) {
587 + final rate = (stringDoubleToBitcoinAmount(feeRate.toString()) / 1000).round();
588 + if (rate < 0) {
589 + return 0;
590 + }
591 + if (rate > _maxFeeRate) {
592 + return _maxFeeRate;
593 + }
594 + return rate;
595 + }
596 +
597 Future<List<int>> feeRates({BasedUtxoNetwork? network}) async {
598 try {
584 - final topDoubleString = await estimatefee(p: 1);
585 - final middleDoubleString = await estimatefee(p: 5);
586 - final bottomDoubleString = await estimatefee(p: 10);
587 - final top = (stringDoubleToBitcoinAmount(topDoubleString.toString()) / 1000).round();
588 - final middle = (stringDoubleToBitcoinAmount(middleDoubleString.toString()) / 1000).round();
589 - final bottom = (stringDoubleToBitcoinAmount(bottomDoubleString.toString()) / 1000).round();
599 + final topDouble = await estimatefee(p: 1);
600 + final middleDouble = await estimatefee(p: 5);
601 + final bottomDouble = await estimatefee(p: 10);
602 + final top = _sanitizeFeeRate(topDouble);
603 + final middle = _sanitizeFeeRate(middleDouble);
604 + final bottom = _sanitizeFeeRate(bottomDouble);
605
606 return [bottom, middle, top];
607 } catch (_) {
cw_bitcoin/lib/electrum_wallet.dart
+23 -17
@@ -750,6 +750,9 @@ abstract class ElectrumWalletBase
750 }
751 }
752
753 + static bool _isValidFeeRates(List<int> feeRates) =>
754 + feeRates.length == 3 && feeRates.every((rate) => rate > 0);
755 +
756 @action
757 Future<void> updateFeeRates() async {
758 if (await checkIfMempoolAPIIsEnabled() && type == WalletType.bitcoin) {
@@ -776,7 +779,7 @@ abstract class ElectrumWalletBase
779 }
780
781 final feeRates = await electrumClient.feeRates(network: network);
779 - if (feeRates != [0, 0, 0]) {
782 + if (_isValidFeeRates(feeRates)) {
783 _feeRates = feeRates;
784 } else if (isTestnet) {
785 _feeRates = [1, 1, 1];
@@ -1024,7 +1027,7 @@ abstract class ElectrumWalletBase
1027 vinOutpoints: utxoDetails.vinOutpoints,
1028 );
1029
1027 - if (fee == 0) {
1030 + if (fee <= 0) {
1031 throw BitcoinTransactionNoFeeException();
1032 }
1033
@@ -1205,7 +1208,7 @@ abstract class ElectrumWalletBase
1208 ));
1209 }
1210
1208 - if (fee == 0) {
1211 + if (fee <= 0) {
1212 throw BitcoinTransactionNoFeeException();
1213 }
1214
@@ -2513,16 +2516,16 @@ abstract class ElectrumWalletBase
2516 Map<String, ElectrumTransactionInfo> historiesWithDetails,
2517 BitcoinAddressType type,
2518 ) async {
2516 -
2519 final addressesByType =
2518 - walletAddresses.allAddresses.where((addr) => addr.type == type).toList();
2520 + walletAddresses.allAddresses.where((addr) => addr.type == type).toList();
2521
2522 final receiveStandard = getAddressBranchByType(hidden: false, legacy: false, type: type);
2521 - final changeStandard = getAddressBranchByType(hidden: true, legacy: false, type: type);
2523 + final changeStandard = getAddressBranchByType(hidden: true, legacy: false, type: type);
2524 final receiveLegacy = getAddressBranchByType(hidden: false, legacy: true, type: type);
2523 - final changeLegacy = getAddressBranchByType(hidden: true, legacy: true, type: type);
2525 + final changeLegacy = getAddressBranchByType(hidden: true, legacy: true, type: type);
2526
2525 - walletAddresses.hiddenAddresses.addAll([...changeStandard, ...changeLegacy].map((e) => e.address));
2527 + walletAddresses.hiddenAddresses
2528 + .addAll([...changeStandard, ...changeLegacy].map((e) => e.address));
2529 await walletAddresses.saveAddressesInBox();
2530 await Future.wait(addressesByType.map((addressRecord) async {
2531 final history = await _fetchAddressHistory(addressRecord, await getCurrentChainTip());
@@ -2637,13 +2640,13 @@ abstract class ElectrumWalletBase
2640
2641 Future<void> fetchTransactionsForAddressTypeBatch(
2642 Map<String, ElectrumTransactionInfo> historiesWithDetails, BitcoinAddressType type) async {
2640 -
2643 final receiveStandard = getAddressBranchByType(hidden: false, legacy: false, type: type);
2642 - final changeStandard = getAddressBranchByType(hidden: true, legacy: false, type: type);
2644 + final changeStandard = getAddressBranchByType(hidden: true, legacy: false, type: type);
2645 final receiveLegacy = getAddressBranchByType(hidden: false, legacy: true, type: type);
2644 - final changeLegacy = getAddressBranchByType(hidden: true, legacy: true, type: type);
2646 + final changeLegacy = getAddressBranchByType(hidden: true, legacy: true, type: type);
2647
2646 - walletAddresses.hiddenAddresses.addAll([...changeStandard, ...changeLegacy].map((e) => e.address));
2648 + walletAddresses.hiddenAddresses
2649 + .addAll([...changeStandard, ...changeLegacy].map((e) => e.address));
2650 await walletAddresses.saveAddressesInBox();
2651
2652 await fetchTransactionsForAddressesBranchBatch(
@@ -2665,7 +2668,7 @@ abstract class ElectrumWalletBase
2668 await fetchTransactionsForAddressesBranchBatch(
2669 historiesWithDetails,
2670 type,
2668 - receiveLegacy,
2671 + receiveLegacy,
2672 isHidden: false,
2673 isLegacyDerivation: true,
2674 );
@@ -2749,10 +2752,13 @@ abstract class ElectrumWalletBase
2752 }
2753 }
2754
2752 - List<BitcoinAddressRecord> getAddressBranchByType({required bool hidden, required bool legacy, required BitcoinAddressType
2753 - type}) => walletAddresses.allAddresses.where((addr) => addr.type == type && addr.isHidden == hidden && addr.isLegacyDerivation == legacy)
2754 - .toList()
2755 - ..sort((a, b) => a.index.compareTo(b.index));
2755 + List<BitcoinAddressRecord> getAddressBranchByType(
2756 + {required bool hidden, required bool legacy, required BitcoinAddressType type}) =>
2757 + walletAddresses.allAddresses
2758 + .where((addr) =>
2759 + addr.type == type && addr.isHidden == hidden && addr.isLegacyDerivation == legacy)
2760 + .toList()
2761 + ..sort((a, b) => a.index.compareTo(b.index));
2762
2763 int _highestUsedIndex(List<BitcoinAddressRecord> addresses) {
2764 for (int i = addresses.length - 1; i >= 0; i--) {