Merge pull request #1491 from cypherstack/fix/desktop-address-book-chooser
What changed, and why it matters
This commit fixes a desktop bug where the address-book chooser was empty unless the user first opened the Address Book page. The fix moves the logic that builds the "My Stack" self-contact into a shared service method and calls it from the chooser too. It also adds a mutex so concurrent calls don't overwrite each other, and improves error handling so one failing wallet doesn't break the whole contact. There is no direct security vulnerability here; it is a reliability/functional bug fix.
No security action required. Treat as a normal functional/reliability fix. Reviewers may want to verify that `editContact` properly persists the contact and that the mutex covers all writers of the "My Stack" contact.
Security signals we found
Adds concurrency control (mutex) around contact update to prevent race conditions/overwrite
Adds defensive error handling so address generation failures don't crash contact creation
Prevents Spark wallets from exposing a transparent fallback address when Spark address generation fails
Evidence from the diff
The change refactors address-book self-contact creation. Previously DesktopAddressBook.initState() built the “My Stack” contact inline. Now AddressBookService.updateMyStackContact(Iterable<Wallet>) centralizes that logic, is called from both DesktopAddressBook and AddressBookAddressChooser, protects updates with a Mutex, and catches per-wallet address-fetch failures. For Spark wallets, failure omits the address (avoiding offering a transparent fallback); for other wallets, it falls back to the cached receiving address. Tests verify fallback behavior, Spark omission, and serialization.
Changed components
lib/services/address_book_service.dartlib/pages_desktop_specific/address_book_view/desktop_address_book.dartlib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/address_book_address_chooser/address_book_address_chooser.darttest/services/address_book_service_test.dartInspect captured patch +299 / −115
### lib/pages_desktop_specific/address_book_view/desktop_address_book.dart
@@ -13,8 +13,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/svg.dart';
import '../../app_config.dart';
-import '../../models/isar/models/blockchain_data/address.dart';
-import '../../models/isar/models/contact_entry.dart';
import '../../pages/address_book_views/subviews/add_address_book_entry_view.dart';
import '../../pages/address_book_views/subviews/address_book_filter_view.dart';
import '../../providers/global/address_book_service_provider.dart';
@@ -26,7 +24,6 @@ import '../../utilities/constants.dart';
import '../../utilities/text_styles.dart';
import '../../utilities/util.dart';
import '../../wallets/crypto_currency/crypto_currency.dart';
-import '../../wallets/wallet/wallet_mixin_interfaces/spark_interface.dart';
import '../../widgets/address_book_card.dart';
import '../../widgets/desktop/desktop_app_bar.dart';
import '../../widgets/desktop/desktop_dialog.dart';
@@ -119,35 +116,9 @@ class _DesktopAddressBook extends ConsumerState<DesktopAddressBook> {
// }
WidgetsBinding.instance.addPostFrameCallback((_) async {
- final List<ContactAddressEntry> addresses = [];
- final wallets = ref.read(pWallets).wallets;
- for (final wallet in wallets) {
- final String addressString;
- if (wallet is SparkInterface &&
- !(wallet.isViewOnly && wallet.viewOnlyType != .spark)) {
- Address? address = await wallet.getCurrentReceivingSparkAddress();
- address ??= await wallet.generateNextSparkAddress(saveToDB: true);
- addressString = address.value;
- } else {
- final address = await wallet.getCurrentReceivingAddress();
- addressString = address?.value ?? wallet.info.cachedReceivingAddress;
- }
-
- addresses.add(
- ContactAddressEntry()
- ..coinName = wallet.info.coin.identifier
- ..address = addressString
- ..label = "Current Receiving"
- ..other = wallet.info.name,
- );
- }
- final self = ContactEntry(
- name: "My ${AppConfig.prefix}",
- addresses: addresses,
- isFavorite: true,
- customId: "default",
- );
- await ref.read(addressBookServiceProvider).editContact(self);
+ await ref
+ .read(addressBookServiceProvider)
+ .updateMyStackContact(ref.read(pWallets).wallets);
});
super.initState();
@@ -286,9 +257,9 @@ class _DesktopAddressBook extends ConsumerState<DesktopAddressBook> {
buttonHeight: ButtonHeight.l,
icon: SvgPicture.asset(
Assets.svg.filter,
- color: Theme.of(
- context,
- ).extension<StackColors>()!.buttonTextSecondary,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .buttonTextSecondary,
),
onPressed: selectCryptocurrency,
),
@@ -299,9 +270,9 @@ class _DesktopAddressBook extends ConsumerState<DesktopAddressBook> {
buttonHeight: ButtonHeight.l,
icon: SvgPicture.asset(
Assets.svg.circlePlus,
- color: Theme.of(
- context,
- ).extension<StackColors>()!.buttonTextPrimary,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .buttonTextPrimary,
),
onPressed: newContact,
),
@@ -343,9 +314,9 @@ class _DesktopAddressBook extends ConsumerState<DesktopAddressBook> {
children: [
if (i > 0)
Container(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
height: 1,
),
Padding(
@@ -414,9 +385,9 @@ class _DesktopAddressBook extends ConsumerState<DesktopAddressBook> {
children: [
if (i > 0)
Container(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
height: 1,
),
Padding(
### lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/address_book_address_chooser/address_book_address_chooser.dart
@@ -14,6 +14,7 @@ import 'package:flutter_svg/flutter_svg.dart';
import '../../../../../models/isar/models/contact_entry.dart';
import '../../../../../providers/global/address_book_service_provider.dart';
+import '../../../../../providers/providers.dart';
import '../../../../../themes/stack_colors.dart';
import '../../../../../utilities/assets.dart';
import '../../../../../utilities/constants.dart';
@@ -25,20 +26,18 @@ import '../../../../../widgets/stack_text_field.dart';
import '../../../../../widgets/textfield_icon_button.dart';
import 'sub_widgets/contact_list_item.dart';
-class AddressBookAddressChooser extends StatefulWidget {
- const AddressBookAddressChooser({
- super.key,
- this.coin,
- });
+class AddressBookAddressChooser extends ConsumerStatefulWidget {
+ const AddressBookAddressChooser({super.key, this.coin});
final CryptoCurrency? coin;
@override
- State<AddressBookAddressChooser> createState() =>
+ ConsumerState<AddressBookAddressChooser> createState() =>
_AddressBookAddressChooserState();
}
-class _AddressBookAddressChooserState extends State<AddressBookAddressChooser> {
+class _AddressBookAddressChooserState
+ extends ConsumerState<AddressBookAddressChooser> {
late final bool isDesktop;
late final TextEditingController _searchController;
late final FocusNode searchFieldFocusNode;
@@ -109,6 +108,16 @@ class _AddressBookAddressChooserState extends State<AddressBookAddressChooser> {
isDesktop = Util.isDesktop;
searchFieldFocusNode = FocusNode();
_searchController = TextEditingController();
+
+ // the "My Stack" contact is otherwise only created by the address book
+ // view, so make sure it exists and is current before choosing from it
+ WidgetsBinding.instance.addPostFrameCallback((_) async {
+ if (!mounted) return;
+ await ref
+ .read(addressBookServiceProvider)
+ .updateMyStackContact(ref.read(pWallets).wallets);
+ });
+
super.initState();
}
@@ -125,9 +134,7 @@ class _AddressBookAddressChooserState extends State<AddressBookAddressChooser> {
children: [
// search field
Padding(
- padding: const EdgeInsets.symmetric(
- horizontal: 32,
- ),
+ padding: const EdgeInsets.symmetric(horizontal: 32),
child: ClipRRect(
borderRadius: BorderRadius.circular(
Constants.size.circularBorderRadius,
@@ -150,71 +157,68 @@ class _AddressBookAddressChooserState extends State<AddressBookAddressChooser> {
height: 1.8,
)
: STextStyles.field(context),
- decoration: standardInputDecoration(
- "Search",
- searchFieldFocusNode,
- context,
- desktopMed: isDesktop,
- ).copyWith(
- prefixIcon: Padding(
- padding: EdgeInsets.symmetric(
- horizontal: isDesktop ? 12 : 10,
- vertical: isDesktop ? 18 : 16,
- ),
- child: SvgPicture.asset(
- Assets.svg.search,
- width: isDesktop ? 20 : 16,
- height: isDesktop ? 20 : 16,
- ),
- ),
- suffixIcon: _searchController.text.isNotEmpty
- ? Padding(
- padding: const EdgeInsets.only(right: 0),
- child: UnconstrainedBox(
- child: Row(
- children: [
- TextFieldIconButton(
- child: const XIcon(),
- onTap: () async {
- setState(() {
- _searchController.text = "";
- _searchTerm = "";
- });
- },
+ decoration:
+ standardInputDecoration(
+ "Search",
+ searchFieldFocusNode,
+ context,
+ desktopMed: isDesktop,
+ ).copyWith(
+ prefixIcon: Padding(
+ padding: EdgeInsets.symmetric(
+ horizontal: isDesktop ? 12 : 10,
+ vertical: isDesktop ? 18 : 16,
+ ),
+ child: SvgPicture.asset(
+ Assets.svg.search,
+ width: isDesktop ? 20 : 16,
+ height: isDesktop ? 20 : 16,
+ ),
+ ),
+ suffixIcon: _searchController.text.isNotEmpty
+ ? Padding(
+ padding: const EdgeInsets.only(right: 0),
+ child: UnconstrainedBox(
+ child: Row(
+ children: [
+ TextFieldIconButton(
+ child: const XIcon(),
+ onTap: () async {
+ setState(() {
+ _searchController.text = "";
+ _searchTerm = "";
+ });
+ },
+ ),
+ ],
),
- ],
- ),
- ),
- )
- : null,
- ),
+ ),
+ )
+ : null,
+ ),
),
),
),
- const SizedBox(
- height: 16,
- ),
+ const SizedBox(height: 16),
Expanded(
child: Padding(
- padding: const EdgeInsets.only(
- left: 32,
- right: 32,
- bottom: 32,
- ),
+ padding: const EdgeInsets.only(left: 32, right: 32, bottom: 32),
child: Consumer(
builder: (context, ref, _) {
List<ContactEntry> contacts = ref
.watch(
- addressBookServiceProvider
- .select((value) => value.contacts),
+ addressBookServiceProvider.select(
+ (value) => value.contacts,
+ ),
)
.toList();
contacts = filter(contacts, _searchTerm);
final favorites = pullOutFavorites(contacts);
- final totalLength = favorites.length +
+ final totalLength =
+ favorites.length +
contacts.length +
2; // +2 for "fav" and "all" headers
@@ -223,23 +227,20 @@ class _AddressBookAddressChooserState extends State<AddressBookAddressChooser> {
shrinkWrap: true,
itemCount: totalLength,
separatorBuilder: (context, index) {
- return const SizedBox(
- height: 10,
- );
+ return const SizedBox(height: 10);
},
itemBuilder: (context, index) {
if (index == 0) {
return Padding(
key: const Key(
"addressBookCAddressChooserFavoritesHeaderItemKey",
),
- padding: const EdgeInsets.only(
- bottom: 10,
- ),
+ padding: const EdgeInsets.only(bottom: 10),
child: Text(
"Favorites",
- style:
- STextStyles.desktopTextExtraExtraSmall(context),
+ style: STextStyles.desktopTextExtraExtraSmall(
+ context,
+ ),
),
);
} else if (index < favorites.length + 1) {
@@ -254,13 +255,12 @@ class _AddressBookAddressChooserState extends State<AddressBookAddressChooser> {
key: const Key(
"addressBookCAddressChooserAllContactsHeaderItemKey",
),
- padding: const EdgeInsets.symmetric(
- vertical: 10,
- ),
+ padding: const EdgeInsets.symmetric(vertical: 10),
child: Text(
"All contacts",
- style:
- STextStyles.desktopTextExtraExtraSmall(context),
+ style: STextStyles.desktopTextExtraExtraSmall(
+ context,
+ ),
),
);
} else {
### lib/services/address_book_service.dart
@@ -10,10 +10,19 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
+import 'package:mutex/mutex.dart';
+
+import '../app_config.dart';
import '../db/isar/main_db.dart';
+import '../models/isar/models/blockchain_data/address.dart';
import '../models/isar/models/contact_entry.dart';
+import '../utilities/logger.dart';
+import '../wallets/wallet/wallet.dart';
+import '../wallets/wallet/wallet_mixin_interfaces/spark_interface.dart';
class AddressBookService extends ChangeNotifier {
+ final Mutex _myStackContactLock = Mutex();
+
ContactEntry getContactById(String id) {
final ContactEntry? contactEntry = MainDB.instance.getContactEntry(id: id);
if (contactEntry == null) {
@@ -78,6 +87,65 @@ class AddressBookService extends ChangeNotifier {
return true;
}
+ /// Rebuilds the "My Stack" contact from the current receiving address of
+ /// each of [wallets].
+ Future<void> updateMyStackContact(Iterable<Wallet> wallets) =>
+ _myStackContactLock.protect(() => _updateMyStackContact(wallets));
+
+ Future<void> _updateMyStackContact(Iterable<Wallet> wallets) async {
+ final List<ContactAddressEntry> addresses = [];
+ for (final wallet in wallets) {
+ final SparkInterface? sparkWallet =
+ wallet is SparkInterface &&
+ !(wallet.isViewOnly && wallet.viewOnlyType != .spark)
+ ? wallet
+ : null;
+ String addressString;
+ try {
+ if (sparkWallet != null) {
+ Address? address = await sparkWallet
+ .getCurrentReceivingSparkAddress();
+ address ??= await sparkWallet.generateNextSparkAddress(
+ saveToDB: true,
+ );
+ addressString = address.value;
+ } else {
+ final address = await wallet.getCurrentReceivingAddress();
+ addressString = address?.value ?? wallet.info.cachedReceivingAddress;
+ }
+ } catch (e, s) {
+ // A Spark wallet's cached address is its transparent one, so leave it
+ // out rather than offer a public address. Other wallets fall back to
+ // the cached address so one failure doesn't drop them or stop the rest.
+ Logging.instance.e(
+ "Failed to get a wallet address for My Stack",
+ error: e,
+ stackTrace: s,
+ );
+ if (sparkWallet != null) continue;
+ addressString = wallet.info.cachedReceivingAddress;
+ }
+ if (addressString.isEmpty) continue;
+
+ addresses.add(
+ ContactAddressEntry()
+ ..coinName = wallet.info.coin.identifier
+ ..address = addressString
+ ..label = "Current Receiving"
+ ..other = wallet.info.name,
+ );
+ }
+
+ await editContact(
+ ContactEntry(
+ name: "My ${AppConfig.prefix}",
+ addresses: addresses,
+ isFavorite: true,
+ customId: "default",
+ ),
+ );
+ }
+
/// Remove address book contact entry from db if it exists
Future<void> removeContact(String id) async {
await MainDB.instance.deleteContactEntry(id: id);
### test/services/address_book_service_test.dart
@@ -0,0 +1,145 @@
+import 'dart:async';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mockito/mockito.dart';
+import 'package:stackwallet/models/isar/models/blockchain_data/address.dart';
+import 'package:stackwallet/models/isar/models/contact_entry.dart';
+import 'package:stackwallet/services/address_book_service.dart';
+import 'package:stackwallet/wallets/crypto_currency/coins/firo.dart';
+import 'package:stackwallet/wallets/isar/models/wallet_info.dart';
+import 'package:stackwallet/wallets/wallet/wallet.dart';
+import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart';
+
+class _TestAddressBookService extends AddressBookService {
+ final writes = <ContactEntry>[];
+
+ @override
+ Future<bool> editContact(ContactEntry editedContact) async {
+ writes.add(editedContact);
+ return true;
+ }
+}
+
+class _TestWallet extends Mock implements Wallet {
+ _TestWallet({required this.info, required this.loadAddress});
+
+ @override
+ final WalletInfo info;
+
+ final Future<Address?> Function() loadAddress;
+
+ @override
+ Future<Address?> getCurrentReceivingAddress() => loadAddress();
+}
+
+class _TestSparkWallet extends Mock
+ implements Wallet<Firo>, SparkInterface<Firo> {
+ _TestSparkWallet({required this.info});
+
+ @override
+ final WalletInfo info;
+
+ @override
+ bool get isViewOnly => false;
+
+ @override
+ Future<Address?> getCurrentReceivingSparkAddress() async => null;
+
+ @override
+ Future<Address> generateNextSparkAddress({required bool saveToDB}) =>
+ throw Exception("spark address generation failed");
+}
+
+WalletInfo _walletInfo(String id) => WalletInfo(
+ walletId: id,
+ name: id,
+ mainAddressType: AddressType.p2wpkh,
+ coinName: "bitcoin",
+ cachedReceivingAddress: "cached-$id",
+);
+
+Address _address(String walletId) => Address(
+ walletId: walletId,
+ value: "address-$walletId",
+ publicKey: const [],
+ derivationIndex: 0,
+ derivationPath: null,
+ type: AddressType.p2wpkh,
+ subType: AddressSubType.receiving,
+);
+
+void main() {
+ test("uses the cached address for a wallet that fails", () async {
+ final service = _TestAddressBookService();
+ final goodWallet = _TestWallet(
+ info: _walletInfo("good"),
+ loadAddress: () async => _address("good"),
+ );
+ final failingWallet = _TestWallet(
+ info: _walletInfo("failing"),
+ loadAddress: () async => throw Exception("temporary failure"),
+ );
+
+ await service.updateMyStackContact([goodWallet, failingWallet]);
+
+ expect(service.writes, hasLength(1));
+ expect(service.writes.single.addresses.map((e) => e.address), [
+ "address-good",
+ "cached-failing",
+ ]);
+ });
+
+ test("leaves out a Spark wallet whose Spark address fails", () async {
+ final service = _TestAddressBookService();
+ final goodWallet = _TestWallet(
+ info: _walletInfo("good"),
+ loadAddress: () async => _address("good"),
+ );
+ final sparkWallet = _TestSparkWallet(
+ info: WalletInfo(
+ walletId: "firo",
+ name: "firo",
+ mainAddressType: AddressType.p2pkh,
+ coinName: "firo",
+ cachedReceivingAddress: "transparent-firo",
+ ),
+ );
+
+ await service.updateMyStackContact([goodWallet, sparkWallet]);
+
+ expect(service.writes, hasLength(1));
+ expect(service.writes.single.addresses.map((e) => e.address), [
+ "address-good",
+ ]);
+ });
+
+ test("serializes My Stack updates in call order", () async {
+ final service = _TestAddressBookService();
+ final firstStarted = Completer<void>();
+ final firstAddress = Completer<Address?>();
+ final firstWallet = _TestWallet(
+ info: _walletInfo("first"),
+ loadAddress: () {
+ firstStarted.complete();
+ return firstAddress.future;
+ },
+ );
+ final secondWallet = _TestWallet(
+ info: _walletInfo("second"),
+ loadAddress: () async => _address("second"),
+ );
+
+ final firstUpdate = service.updateMyStackContact([firstWallet]);
+ await firstStarted.future;
+ final secondUpdate = service.updateMyStackContact([secondWallet]);
+ await Future<void>.delayed(Duration.zero);
+
+ expect(service.writes, isEmpty);
+
+ firstAddress.complete(_address("first"));
+ await Future.wait([firstUpdate, secondUpdate]);
+
+ expect(service.writes, hasLength(2));
+ expect(service.writes.last.addresses.single.address, "address-second");
+ });
+}Why this scored 19/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.