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--) {