fix: stop refetching the desktop fee rates on every rebuild
What changed, and why it matters
This commit fixes a performance bug in the desktop version of Stack Wallet where the app repeatedly re-fetched Bitcoin-style fee rates every time the on-screen fee form rebuilt. The change caches the fee result and only fetches again if the previous attempt failed. It is primarily a reliability/efficiency improvement, not a security fix.
Treat as a routine bug-fix/performance patch. No special security action is required, though it may be worth confirming that stale fee rates are refreshed when the user re-opens the send flow, since the cache lives only for the widget lifetime.
Security signals we found
Unnecessary repeated network requests reduced
Future result cached to avoid re-execution on widget rebuild
Error path clears cache to allow retry
No input validation, crypto, or authentication changes
Evidence from the diff
The patch replaces a ref.watch(...fees) call inside a FutureBuilder with a cached Future<FeeObject>? _fees that is initialized once via ref.read(...).getWallet(...).fees. On error, _fees is cleared so the next rebuild retries. A new widget test verifies that rebuilds do not trigger extra fetches and that a failed fetch is retried. This stops unnecessary network round-trips and widget flicker on every UI rebuild.
Changed components
lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.darttest/pages_desktop_specific/wallet/desktop_send_fee_form_test.dartInspect captured patch +154 / −5
### lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
+import '../../../../models/paymint/fee_object_model.dart';
import '../../../../pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart';
import '../../../../providers/providers.dart';
import '../../../../providers/ui/fee_rate_type_state_provider.dart';
@@ -56,6 +57,7 @@ class _DesktopSendFeeFormState extends ConsumerState<DesktopSendFeeForm> {
];
late final CryptoCurrency cryptoCurrency;
+ Future<FeeObject>? _fees;
bool get isEth => cryptoCurrency is Ethereum;
@@ -174,12 +176,15 @@ class _DesktopSendFeeFormState extends ConsumerState<DesktopSendFeeForm> {
padding: const EdgeInsets.all(10),
child: (feeSelectionResult?.$2 == null)
? FutureBuilder(
- future: ref.watch(
- pWallets.select(
- (value) => value.getWallet(widget.walletId).fees,
- ),
- ),
+ future: _fees ??= ref
+ .read(pWallets)
+ .getWallet(widget.walletId)
+ .fees,
builder: (context, snapshot) {
+ if (snapshot.connectionState == ConnectionState.done &&
+ snapshot.hasError) {
+ _fees = null;
+ }
if (snapshot.connectionState == ConnectionState.done &&
snapshot.hasData) {
return DesktopFeeItem(
### test/pages_desktop_specific/wallet/desktop_send_fee_form_test.dart
@@ -0,0 +1,144 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:stackwallet/models/isar/models/blockchain_data/address.dart';
+import 'package:stackwallet/models/isar/stack_theme.dart';
+import 'package:stackwallet/models/paymint/fee_object_model.dart';
+import 'package:stackwallet/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart';
+import 'package:stackwallet/providers/global/wallets_provider.dart';
+import 'package:stackwallet/providers/wallet/desktop_fee_providers.dart';
+import 'package:stackwallet/themes/stack_colors.dart';
+import 'package:stackwallet/utilities/amount/amount.dart';
+import 'package:stackwallet/utilities/amount/amount_formatter.dart';
+import 'package:stackwallet/utilities/amount/amount_unit.dart';
+import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart';
+import 'package:stackwallet/wallets/isar/models/wallet_info.dart';
+import 'package:stackwallet/wallets/isar/providers/wallet_info_provider.dart';
+import 'package:stackwallet/wallets/wallet/wallet.dart';
+import 'package:stackwallet/widgets/desktop/desktop_fee_dialog.dart';
+
+import '../../sample_data/theme_json.dart';
+import '../../wallets/support/xelis_test_fakes.dart';
+
+const _walletId = "wallet id";
+final _coin = Bitcoin(CryptoCurrencyNetwork.main);
+final _info = WalletInfo(
+ walletId: _walletId,
+ name: "Bitcoin wallet",
+ mainAddressType: AddressType.p2wpkh,
+ coinName: _coin.identifier,
+);
+
+/// Bitcoin wallet counting its fee rate fetches, the first [failingFetches]
+/// of which fail.
+class _FeeWallet implements Wallet<Bitcoin> {
+ _FeeWallet({this.failingFetches = 0});
+
+ final int failingFetches;
+ var feeFetches = 0;
+
+ @override
+ WalletInfo get info => _info;
+
+ @override
+ Future<FeeObject> get fees async {
+ feeFetches++;
+ if (feeFetches <= failingFetches) throw Exception("node unreachable");
+ return FeeObject(
+ numberOfBlocksFast: 1,
+ numberOfBlocksAverage: 5,
+ numberOfBlocksSlow: 20,
+ fast: BigInt.from(20000),
+ medium: BigInt.from(10000),
+ slow: BigInt.from(5000),
+ );
+ }
+
+ @override
+ Future<Amount> estimateFeeFor(Amount amount, BigInt feeRate) async =>
+ Amount(rawValue: BigInt.from(1410), fractionDigits: 8);
+
+ @override
+ dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
+}
+
+/// Pump the fee form for [wallet] and return its parent's setState.
+Future<StateSetter> _pumpForm(WidgetTester tester, _FeeWallet wallet) async {
+ final theme = StackTheme.fromJson(json: lightThemeJsonMap);
+ late StateSetter rebuild;
+ await tester.pumpWidget(
+ ProviderScope(
+ overrides: [
+ pWallets.overrideWithValue(ConfirmationWallets(wallet)),
+ pWalletCoin(_walletId).overrideWithValue(_coin),
+ pAmountFormatter(_coin).overrideWithValue(
+ AmountFormatter(
+ unit: AmountUnit.normal,
+ locale: "en_US",
+ coin: _coin,
+ maxDecimals: 8,
+ ),
+ ),
+ ],
+ child: MaterialApp(
+ theme: ThemeData(extensions: [StackColors.fromStackColorTheme(theme)]),
+ home: Material(
+ child: StatefulBuilder(
+ builder: (context, setState) {
+ rebuild = setState;
+ return DesktopSendFeeForm(
+ walletId: _walletId,
+ isToken: false,
+ onCustomFeeSliderChanged: (_) {},
+ onCustomFeeOptionChanged: () {},
+ );
+ },
+ ),
+ ),
+ ),
+ ),
+ );
+ await tester.pump();
+ return rebuild;
+}
+
+void main() {
+ testWidgets("a rebuild keeps the fee rates and the amount", (tester) async {
+ final wallet = _FeeWallet();
+ final rebuild = await _pumpForm(tester, wallet);
+ final container = ProviderScope.containerOf(
+ tester.element(find.byType(DesktopSendFeeForm)),
+ );
+ final amount = Amount(rawValue: BigInt.from(22000), fractionDigits: 8);
+ container.read(sendAmountProvider.notifier).state = amount;
+ await tester.pump();
+
+ rebuild(() {});
+ await tester.pump();
+ await tester.pump();
+
+ expect(wallet.feeFetches, 1);
+ expect(container.read(sendAmountProvider), amount);
+ });
+
+ testWidgets("a rebuild fetches the fee rates again after a failure", (
+ tester,
+ ) async {
+ final wallet = _FeeWallet(failingFetches: 1);
+ final rebuild = await _pumpForm(tester, wallet);
+ expect(find.byType(DesktopFeeItem), findsNothing);
+
+ rebuild(() {});
+ await tester.pump();
+ await tester.pump();
+
+ expect(wallet.feeFetches, 2);
+ expect(find.byType(DesktopFeeItem), findsOneWidget);
+
+ rebuild(() {});
+ await tester.pump();
+
+ expect(wallet.feeFetches, 2);
+ expect(find.byType(DesktopFeeItem), findsOneWidget);
+ });
+}Why this scored 18/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.