Merge branch 'staging' into building_dialog_cancel
What changed, and why it matters
This commit is a routine merge from the project's staging branch into a feature branch. The visible code changes fix small UI/UX bugs in Stack Wallet's send screens: clearing an internal address-validation cache when the form is reset, making desktop fee estimates fetch only once (and retry after a failure), and adding QR-code scan buttons to two desktop token send pages. There is no direct evidence in the commit that these were security vulnerabilities, and the commit message does not describe security relevance.
No immediate security action is required. Treat as a normal QA/UX merge. If reviewing for release, verify that the fee-form retry behavior and address-provider reset do not introduce edge-case regressions in transaction validation flows.
Security signals we found
Clearing stale address-provider state on form reset reduces the risk of stale validation data influencing a subsequent transaction
Fee-form caching prevents repeated network calls and accidental fee UI flicker/state races on rebuild
QR scan buttons are UX additions with no visible parser/validation logic changes
Evidence from the diff
The diff contains four functional changes: (1) _setValidAddressProviders("") is called in SendView._reset() and DesktopSend._reset() to clear previously resolved address providers when the user clears the send form; (2) DesktopSendFeeForm caches the wallet fee Future in _fees so that Riverpod rebuilds do not re-trigger fee fetches, nulls the cache on error so a retry will re-fetch, and adds a ref.listen(sendAmountProvider, (_, __) {}) to keep the amount provider alive across rebuilds; (3) QR-code scan buttons are added to DesktopSolTokenSend and DesktopTokenSend when the recipient field is empty; (4) a new widget test file verifies the fee-form caching/retry behavior. No cryptographic, authentication, or network-trust changes are present.
Changed components
lib/pages/send_view/send_view.dartlib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dartlib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dartlib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dartlib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dartInspect captured patch +194 / −5
### lib/pages/send_view/send_view.dart
@@ -1217,6 +1217,7 @@ class _SendViewState extends ConsumerState<SendView> {
_address = "";
_addressToggleFlag = false;
_setOpReturnData(null);
+ _setValidAddressProviders("");
setState(() {});
}
### lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart
@@ -870,6 +870,7 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
_addressToggleFlag = false;
_syncFeeAmount(null);
_setOpReturnData(null);
+ _setValidAddressProviders("");
setState(() {});
}
### 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;
@@ -106,6 +108,8 @@ class _DesktopSendFeeFormState extends ConsumerState<DesktopSendFeeForm> {
@override
Widget build(BuildContext context) {
+ // Keep the send amount alive for the fee estimates.
+ ref.listen(sendAmountProvider, (_, __) {});
final isCustomFee = ref.watch(feeRateTypeDesktopStateProvider).isCustom;
final locale = ref.watch(
localeServiceChangeNotifierProvider.select((value) => value.locale),
@@ -174,12 +178,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(
### lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart
@@ -46,6 +46,7 @@ import '../../../../widgets/desktop/qr_code_scanner_dialog.dart';
import '../../../../widgets/desktop/secondary_button.dart';
import '../../../../widgets/icon_widgets/addressbook_icon.dart';
import '../../../../widgets/icon_widgets/clipboard_icon.dart';
+import '../../../../widgets/icon_widgets/qrcode_icon.dart';
import '../../../../widgets/icon_widgets/x_icon.dart';
import '../../../../widgets/stack_text_field.dart';
import '../../../../widgets/textfield_icon_button.dart';
@@ -1043,6 +1044,13 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
},
child: const AddressBookIcon(),
),
+ if (sendToController.text.isEmpty)
+ TextFieldIconButton(
+ semanticsLabel: "Scan QR Button. Opens Camera For Scanning QR Code.",
+ key: const Key("sendViewScanQrButtonKey"),
+ onTap: scanQr,
+ child: const QrCodeIcon(),
+ ),
],
),
),
### lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart
@@ -51,6 +51,7 @@ import '../../../../widgets/desktop/secondary_button.dart';
import '../../../../widgets/eth_fee_form.dart';
import '../../../../widgets/icon_widgets/addressbook_icon.dart';
import '../../../../widgets/icon_widgets/clipboard_icon.dart';
+import '../../../../widgets/icon_widgets/qrcode_icon.dart';
import '../../../../widgets/icon_widgets/x_icon.dart';
import '../../../../widgets/stack_text_field.dart';
import '../../../../widgets/textfield_icon_button.dart';
@@ -1041,6 +1042,13 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
},
child: const AddressBookIcon(),
),
+ if (sendToController.text.isEmpty)
+ TextFieldIconButton(
+ semanticsLabel: "Scan QR Button. Opens Camera For Scanning QR Code.",
+ key: const Key("sendViewScanQrButtonKey"),
+ onTap: scanQr,
+ child: const QrCodeIcon(),
+ ),
],
),
),
### test/pages_desktop_specific/wallet/desktop_send_fee_form_test.dart
@@ -0,0 +1,164 @@
+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);
+ });
+
+ testWidgets("the amount is kept while no fee estimate is shown", (
+ tester,
+ ) async {
+ final wallet = _FeeWallet(failingFetches: 1);
+ 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(find.byType(DesktopFeeItem), findsOneWidget);
+ expect(container.read(sendAmountProvider), amount);
+ });
+}Why this scored 20/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.