From f1a9b48c669832c524a808a002e3de63a6ffc7a2 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 24 Aug 2026 18:53:01 -0500 Subject: [PATCH 1/2] chore: dart format boost transaction view Formatter-only change so later edits to this file pass the CI format gate. --- .../tx_v2/boost_transaction_view.dart | 122 +++++++++--------- 1 file changed, 58 insertions(+), 64 deletions(-) diff --git a/lib/pages/wallet_view/transaction_views/tx_v2/boost_transaction_view.dart b/lib/pages/wallet_view/transaction_views/tx_v2/boost_transaction_view.dart index 308210124d..5f43a6bfde 100644 --- a/lib/pages/wallet_view/transaction_views/tx_v2/boost_transaction_view.dart +++ b/lib/pages/wallet_view/transaction_views/tx_v2/boost_transaction_view.dart @@ -71,11 +71,10 @@ class _BoostTransactionViewState extends ConsumerState { if (_newRate <= rate) { await showDialog( context: context, - builder: - (_) => const StackOkDialog( - title: "Error", - message: "New fee rate must be greater than the current rate.", - ), + builder: (_) => const StackOkDialog( + title: "Error", + message: "New fee rate must be greater than the current rate.", + ), ); return; } @@ -98,12 +97,11 @@ class _BoostTransactionViewState extends ConsumerState { if (txData == null && mounted) { await showDialog( context: context, - builder: - (_) => StackOkDialog( - title: "RBF send error", - message: ex?.toString() ?? "Unknown error found", - maxWidth: 600, - ), + builder: (_) => StackOkDialog( + title: "RBF send error", + message: ex?.toString() ?? "Unknown error found", + maxWidth: 600, + ), ); return; } else { @@ -112,18 +110,17 @@ class _BoostTransactionViewState extends ConsumerState { unawaited( showDialog( context: context, - builder: - (context) => DesktopDialog( - maxHeight: MediaQuery.of(context).size.height - 64, - maxWidth: 580, - child: ConfirmTransactionView( - txData: txData!, - walletId: walletId, - onSuccess: () {}, - // isPaynymTransaction: isPaynymSend, TODO ? - routeOnSuccessName: DesktopHomeView.routeName, - ), - ), + builder: (context) => DesktopDialog( + maxHeight: MediaQuery.of(context).size.height - 64, + maxWidth: 580, + child: ConfirmTransactionView( + txData: txData!, + walletId: walletId, + onSuccess: () {}, + // isPaynymTransaction: isPaynymSend, TODO ? + routeOnSuccessName: DesktopHomeView.routeName, + ), + ), ), ); } else if (mounted) { @@ -131,13 +128,12 @@ class _BoostTransactionViewState extends ConsumerState { Navigator.of(context).push( RouteGenerator.getRoute( shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, - builder: - (_) => ConfirmTransactionView( - txData: txData!, - walletId: walletId, - // isPaynymTransaction: isPaynymSend, TODO ? - onSuccess: () {}, - ), + builder: (_) => ConfirmTransactionView( + txData: txData!, + walletId: walletId, + // isPaynymTransaction: isPaynymSend, TODO ? + onSuccess: () {}, + ), settings: const RouteSettings( name: ConfirmTransactionView.routeName, ), @@ -180,44 +176,43 @@ class _BoostTransactionViewState extends ConsumerState { return ConditionalParent( condition: !isDesktop, - builder: - (child) => Background( - child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, - appBar: AppBar( - backgroundColor: - Theme.of(context).extension()!.background, - leading: AppBarBackButton( - onPressed: () async { - Navigator.of(context).pop(); - }, - ), - title: Text( - "Boost transaction", - style: STextStyles.navBarTitle(context), - ), - ), - body: SafeArea(child: child), + builder: (child) => Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + backgroundColor: Theme.of( + context, + ).extension()!.background, + leading: AppBarBackButton( + onPressed: () async { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Boost transaction", + style: STextStyles.navBarTitle(context), ), ), + body: SafeArea(child: child), + ), + ), child: Padding( - padding: - isDesktop - ? const EdgeInsets.only(left: 32, right: 32, bottom: 32) - : const EdgeInsets.all(12), + padding: isDesktop + ? const EdgeInsets.only(left: 32, right: 32, bottom: 32) + : const EdgeInsets.all(12), child: ConditionalParent( condition: isDesktop, builder: (child) { return Column( children: [ RoundedWhiteContainer( - borderColor: - isDesktop - ? Theme.of( - context, - ).extension()!.backgroundAppBar - : null, + borderColor: isDesktop + ? Theme.of( + context, + ).extension()!.backgroundAppBar + : null, padding: const EdgeInsets.all(0), child: child, ), @@ -236,11 +231,10 @@ class _BoostTransactionViewState extends ConsumerState { children: [ ConditionalParent( condition: isDesktop, - builder: - (child) => RoundedWhiteContainer( - padding: EdgeInsets.zero, - child: child, - ), + builder: (child) => RoundedWhiteContainer( + padding: EdgeInsets.zero, + child: child, + ), child: Column( children: [ DetailItem( From fb2add3038de6e59b7af7e6f94962e669a4b7bed Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 24 Aug 2026 18:53:07 -0500 Subject: [PATCH 2/2] feat(send): save recipient to contacts from the confirm screen Add an opt-in toggle, with an optional contact name, to the confirm transaction screen on mobile and desktop. When it is on, the recipient is written to the address book once the send goes through. Only sends with a single distinct non change recipient qualify. Trade, PayNym, PayNym notification, fee bump and Salvium stake sends are excluded because their destination is single use or the wallet's own address. Saving runs after the transaction is on its way and never surfaces its own failure as a send failure; an address already in the book is left alone. Closes #1263 --- .../send_view/confirm_transaction_view.dart | 105 +++++++- lib/pages/send_view/save_recipient.dart | 120 +++++++++ .../sub_widgets/save_recipient_controls.dart | 84 ++++++ .../tx_v2/boost_transaction_view.dart | 2 + .../save_recipient_controls_test.dart | 88 ++++++ test/pages/send_view/save_recipient_test.dart | 253 ++++++++++++++++++ 6 files changed, 648 insertions(+), 4 deletions(-) create mode 100644 lib/pages/send_view/save_recipient.dart create mode 100644 lib/pages/send_view/sub_widgets/save_recipient_controls.dart create mode 100644 test/pages/send_view/save_recipient_controls_test.dart create mode 100644 test/pages/send_view/save_recipient_test.dart diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index b54b3b071e..69e11b0d9c 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -26,6 +26,7 @@ import '../../notifications/show_flush_bar.dart'; import '../../pages_desktop_specific/coin_control/desktop_coin_control_use_dialog.dart'; import '../../pages_desktop_specific/my_stack_view/wallet_view/desktop_wallet_view.dart'; import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_auth_send.dart'; +import '../../providers/global/address_book_service_provider.dart'; import '../../providers/providers.dart'; import '../../providers/wallet/public_private_balance_state_provider.dart'; import '../../route_generator.dart'; @@ -66,8 +67,10 @@ import '../../widgets/textfield_icon_button.dart'; import '../../wl_gen/interfaces/libepiccash_interface.dart'; import '../pinpad_views/lock_screen_view.dart'; import '../wallet_view/wallet_view.dart'; +import 'save_recipient.dart'; import 'sub_widgets/epic_slatepack_dialog.dart'; import 'sub_widgets/mwc_slatepack_dialog.dart'; +import 'sub_widgets/save_recipient_controls.dart'; import 'sub_widgets/sending_transaction_dialog.dart'; class ConfirmTransactionView extends ConsumerStatefulWidget { @@ -80,6 +83,7 @@ class ConfirmTransactionView extends ConsumerStatefulWidget { this.isTradeTransaction = false, this.isPaynymTransaction = false, this.isPaynymNotificationTransaction = false, + this.isRbfTransaction = false, this.isTokenTx = false, this.onSuccessInsteadOfRouteOnSuccess, }); @@ -92,6 +96,7 @@ class ConfirmTransactionView extends ConsumerStatefulWidget { final bool isTradeTransaction; final bool isPaynymTransaction; final bool isPaynymNotificationTransaction; + final bool isRbfTransaction; final bool isTokenTx; final VoidCallback? onSuccessInsteadOfRouteOnSuccess; final VoidCallback onSuccess; @@ -107,6 +112,11 @@ class _ConfirmTransactionViewState late final String routeOnSuccessName; late final bool isDesktop; + late final SaveRecipientOption _saveRecipient; + + late final FocusNode _saveRecipientFocusNode; + late final TextEditingController saveRecipientNameController; + late final FocusNode _noteFocusNode; late final TextEditingController noteController; @@ -150,7 +160,9 @@ class _ConfirmTransactionViewState } /// Handle MWC slatepack creation for manual exchange. - Future _handleMwcSlatepackCreation( + /// + /// Returns whether the slatepack was created. + Future _handleMwcSlatepackCreation( BuildContext context, MimblewimblecoinWallet wallet, ) async { @@ -203,6 +215,8 @@ class _ConfirmTransactionViewState } } } + + return true; } catch (e, s) { Logging.instance.e('Failed to create MWC slatepack: $e\n$s'); @@ -228,11 +242,15 @@ class _ConfirmTransactionViewState ), ); } + + return false; } } /// Handle Epic Cash slate creation for manual exchange. - Future _handleEpicSlatepackCreation( + /// + /// Returns whether the slate was created. + Future _handleEpicSlatepackCreation( BuildContext context, EpiccashWallet wallet, ) async { @@ -282,6 +300,8 @@ class _ConfirmTransactionViewState } } } + + return true; } catch (e, s) { Logging.instance.e('Failed to create Epic Cash slate: $e\n$s'); @@ -307,6 +327,39 @@ class _ConfirmTransactionViewState ), ); } + + return false; + } + } + + Future _saveRecipientAfterSend(String coinIdentifier) async { + final address = _saveRecipient.addressToSave; + if (address == null) { + return; + } + + try { + final addressBookService = ref.read(addressBookServiceProvider); + final outcome = await saveRecipient( + address: address, + coinIdentifier: coinIdentifier, + name: saveRecipientNameController.text, + existingContacts: addressBookService.contacts, + addContact: addressBookService.addContact, + ); + if (outcome.result == SaveRecipientResult.failed) { + Logging.instance.w( + 'Transaction sent, but recipient could not be saved', + error: outcome.error, + stackTrace: outcome.stackTrace, + ); + } + } catch (error, stackTrace) { + Logging.instance.w( + 'Transaction sent, but recipient could not be saved', + error: error, + stackTrace: stackTrace, + ); } } @@ -397,10 +450,13 @@ class _ConfirmTransactionViewState if (transactionMethod == 'slatepack') { // Handle slatepack creation instead of direct send. - await _handleMwcSlatepackCreation( + final created = await _handleMwcSlatepackCreation( context, wallet as MimblewimblecoinWallet, ); + if (created) { + await _saveRecipientAfterSend(coin.identifier); + } closeSendingDialog(); return; // Exit early, don't continue with normal transaction flow. } else { @@ -421,10 +477,13 @@ class _ConfirmTransactionViewState if (epicTransactionMethod == 'slatepack') { // Handle slatepack creation instead of direct send. - await _handleEpicSlatepackCreation( + final created = await _handleEpicSlatepackCreation( context, wallet as EpiccashWallet, ); + if (created) { + await _saveRecipientAfterSend(coin.identifier); + } closeSendingDialog(); return; // Exit early, don't continue with normal transaction flow. } else { @@ -467,6 +526,8 @@ class _ConfirmTransactionViewState ); } + await _saveRecipientAfterSend(coin.identifier); + if (widget.isTokenTx) { if (wallet is SolanaWallet) { unawaited(ref.read(pCurrentSolanaTokenWallet)!.refresh()); @@ -589,6 +650,17 @@ class _ConfirmTransactionViewState routeOnSuccessName = widget.routeOnSuccessName ?? (Util.isDesktop ? DesktopWalletView.routeName : WalletView.routeName); + _saveRecipient = SaveRecipientOption( + address: savableRecipientAddress( + txData: widget.txData, + isTradeTransaction: widget.isTradeTransaction, + isPaynymTransaction: widget.isPaynymTransaction, + isPaynymNotificationTransaction: widget.isPaynymNotificationTransaction, + isRbfTransaction: widget.isRbfTransaction, + ), + ); + _saveRecipientFocusNode = FocusNode(); + saveRecipientNameController = TextEditingController(); _noteFocusNode = FocusNode(); noteController = TextEditingController(); noteController.text = widget.txData.note ?? ""; @@ -602,9 +674,11 @@ class _ConfirmTransactionViewState @override void dispose() { + saveRecipientNameController.dispose(); noteController.dispose(); onChainNoteController.dispose(); + _saveRecipientFocusNode.dispose(); _noteFocusNode.dispose(); _onChainNoteFocusNode.dispose(); super.dispose(); @@ -613,6 +687,7 @@ class _ConfirmTransactionViewState @override Widget build(BuildContext context) { final coin = ref.watch(pWalletCoin(walletId)); + final canSaveRecipient = _saveRecipient.isOffered; final String unit; final wallet = ref.watch(pWallets).getWallet(walletId); @@ -897,6 +972,18 @@ class _ConfirmTransactionViewState ], ), ), + if (canSaveRecipient) const SizedBox(height: 12), + if (canSaveRecipient) + RoundedWhiteContainer( + child: SaveRecipientControls( + enabled: _saveRecipient.enabled, + isDesktop: false, + onChanged: (value) => + setState(() => _saveRecipient.enabled = value), + controller: saveRecipientNameController, + focusNode: _saveRecipientFocusNode, + ), + ), ], ), if (isDesktop) @@ -1366,6 +1453,16 @@ class _ConfirmTransactionViewState ), ), const SizedBox(height: 20), + if (canSaveRecipient) + SaveRecipientControls( + enabled: _saveRecipient.enabled, + isDesktop: true, + onChanged: (value) => + setState(() => _saveRecipient.enabled = value), + controller: saveRecipientNameController, + focusNode: _saveRecipientFocusNode, + ), + if (canSaveRecipient) const SizedBox(height: 12), ], ), ), diff --git a/lib/pages/send_view/save_recipient.dart b/lib/pages/send_view/save_recipient.dart new file mode 100644 index 0000000000..419b5355f0 --- /dev/null +++ b/lib/pages/send_view/save_recipient.dart @@ -0,0 +1,120 @@ +import 'package:uuid/uuid.dart'; + +import '../../models/isar/models/contact_entry.dart'; +import '../../wallets/models/tx_data.dart'; + +enum SaveRecipientResult { saved, alreadySaved, failed } + +typedef SaveRecipientOutcome = ({ + SaveRecipientResult result, + Object? error, + StackTrace? stackTrace, +}); + +/// Confirm screen state behind the optional "save recipient to contacts" step. +/// +/// The address book records who was paid, so saving is opt-in: [enabled] starts +/// off and [addressToSave] stays null until the user turns it on. +class SaveRecipientOption { + SaveRecipientOption({required this.address}); + + /// The address this send may offer to save, or null if it may not offer one. + final String? address; + + bool enabled = false; + + bool get isOffered => address != null; + + /// The address to persist once the send succeeds, or null if none should be. + String? get addressToSave => enabled ? address : null; +} + +/// The address a send may offer to save, or null when it may not offer one. +/// +/// A savable send has exactly one distinct non change recipient: anything else +/// has no single address to name, or is not a payment to someone else at all. +/// The excluded flows all pay a single use address — a PayNym payment or +/// notification address, an exchange deposit address, the destination of a +/// transaction being fee bumped (already offered when it was first sent), or a +/// Salvium stake, whose "recipient" is the sending wallet itself. Saving those +/// invites address reuse later. +String? savableRecipientAddress({ + required TxData txData, + required bool isTradeTransaction, + required bool isPaynymTransaction, + required bool isPaynymNotificationTransaction, + required bool isRbfTransaction, +}) { + if (isTradeTransaction || + isPaynymTransaction || + isPaynymNotificationTransaction || + isRbfTransaction || + txData.salviumStakeTx) { + return null; + } + + final addresses = { + ...?txData.recipients + ?.where((recipient) => !recipient.isChange) + .map((recipient) => recipient.address.trim()) + .where((address) => address.isNotEmpty), + ...?txData.sparkRecipients + ?.where((recipient) => !recipient.isChange) + .map((recipient) => recipient.address.trim()) + .where((address) => address.isNotEmpty), + }; + + return addresses.length == 1 ? addresses.single : null; +} + +/// Adds [address] to the address book unless it is already there. +/// +/// Runs after the transaction is on its way, so it reports failures instead of +/// throwing them at a caller that can no longer undo the send. +Future saveRecipient({ + required String address, + required String coinIdentifier, + required String name, + required List existingContacts, + required Future Function(ContactEntry contact) addContact, +}) async { + try { + final alreadySaved = existingContacts.any( + (contact) => contact.addresses.any( + (entry) => entry.address == address && entry.coinName == coinIdentifier, + ), + ); + if (alreadySaved) { + return ( + result: SaveRecipientResult.alreadySaved, + error: null, + stackTrace: null, + ); + } + + final entry = ContactAddressEntry() + ..coinName = coinIdentifier + ..address = address + ..label = 'Sent to' + ..other = null; + final contact = ContactEntry( + name: name.trim().isEmpty ? 'Saved recipient' : name.trim(), + addresses: [entry], + isFavorite: false, + customId: const Uuid().v1(), + ); + + final saved = await addContact(contact); + return ( + result: saved ? SaveRecipientResult.saved : SaveRecipientResult.failed, + error: null, + stackTrace: null, + ); + } catch (error, stackTrace) { + return ( + result: SaveRecipientResult.failed, + error: error, + stackTrace: stackTrace, + ); + } +} diff --git a/lib/pages/send_view/sub_widgets/save_recipient_controls.dart b/lib/pages/send_view/sub_widgets/save_recipient_controls.dart new file mode 100644 index 0000000000..37236bac99 --- /dev/null +++ b/lib/pages/send_view/sub_widgets/save_recipient_controls.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; + +import '../../../themes/stack_colors.dart'; +import '../../../utilities/constants.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../widgets/custom_buttons/draggable_switch_button.dart'; +import '../../../widgets/stack_text_field.dart'; + +class SaveRecipientControls extends StatelessWidget { + const SaveRecipientControls({ + super.key, + required this.enabled, + required this.isDesktop, + required this.onChanged, + required this.controller, + required this.focusNode, + }); + + final bool enabled; + final bool isDesktop; + final ValueChanged onChanged; + final TextEditingController controller; + final FocusNode focusNode; + + @override + Widget build(BuildContext context) { + final labelStyle = isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldActiveSearchIconRight, + ) + : STextStyles.smallMed12(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text('Save recipient to contacts', style: labelStyle), + ), + const SizedBox(width: 12), + SizedBox( + height: 20, + width: 40, + child: DraggableSwitchButton( + isOn: enabled, + onValueChanged: onChanged, + ), + ), + ], + ), + if (enabled) SizedBox(height: isDesktop ? 10 : 8), + if (enabled) + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + controller: controller, + focusNode: focusNode, + autocorrect: !isDesktop, + enableSuggestions: !isDesktop, + style: isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldActiveText, + ) + : STextStyles.field(context), + decoration: standardInputDecoration( + 'Contact name', + focusNode, + context, + desktopMed: isDesktop, + ), + ), + ), + ], + ); + } +} diff --git a/lib/pages/wallet_view/transaction_views/tx_v2/boost_transaction_view.dart b/lib/pages/wallet_view/transaction_views/tx_v2/boost_transaction_view.dart index 5f43a6bfde..95ddb8111d 100644 --- a/lib/pages/wallet_view/transaction_views/tx_v2/boost_transaction_view.dart +++ b/lib/pages/wallet_view/transaction_views/tx_v2/boost_transaction_view.dart @@ -118,6 +118,7 @@ class _BoostTransactionViewState extends ConsumerState { walletId: walletId, onSuccess: () {}, // isPaynymTransaction: isPaynymSend, TODO ? + isRbfTransaction: true, routeOnSuccessName: DesktopHomeView.routeName, ), ), @@ -132,6 +133,7 @@ class _BoostTransactionViewState extends ConsumerState { txData: txData!, walletId: walletId, // isPaynymTransaction: isPaynymSend, TODO ? + isRbfTransaction: true, onSuccess: () {}, ), settings: const RouteSettings( diff --git a/test/pages/send_view/save_recipient_controls_test.dart b/test/pages/send_view/save_recipient_controls_test.dart new file mode 100644 index 0000000000..3c95805bc3 --- /dev/null +++ b/test/pages/send_view/save_recipient_controls_test.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/pages/send_view/sub_widgets/save_recipient_controls.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/widgets/custom_buttons/draggable_switch_button.dart'; + +import '../../sample_data/theme_json.dart'; + +void main() { + for (final isDesktop in [false, true]) { + testWidgets( + '${isDesktop ? 'desktop' : 'mobile'} contact saving is opt-in', + (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + extensions: [ + StackColors.fromStackColorTheme( + StackTheme.fromJson(json: lightThemeJsonMap), + ), + ], + ), + home: MediaQuery( + data: const MediaQueryData( + size: Size(240, 600), + textScaler: TextScaler.linear(2), + ), + child: Scaffold( + body: SizedBox( + width: 240, + child: _Harness(isDesktop: isDesktop), + ), + ), + ), + ), + ); + + expect(_isOn(tester), isFalse); + expect(find.byType(TextField), findsNothing); + + await tester.tap(find.byType(DraggableSwitchButton)); + await tester.pump(); + + expect(_isOn(tester), isTrue); + expect(find.byType(TextField), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); + } +} + +bool _isOn(WidgetTester tester) => tester + .widget(find.byType(DraggableSwitchButton)) + .isOn; + +class _Harness extends StatefulWidget { + const _Harness({required this.isDesktop}); + + final bool isDesktop; + + @override + State<_Harness> createState() => _HarnessState(); +} + +class _HarnessState extends State<_Harness> { + final controller = TextEditingController(); + final focusNode = FocusNode(); + var enabled = false; + + @override + void dispose() { + controller.dispose(); + focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SaveRecipientControls( + enabled: enabled, + isDesktop: widget.isDesktop, + onChanged: (value) => setState(() => enabled = value), + controller: controller, + focusNode: focusNode, + ); + } +} diff --git a/test/pages/send_view/save_recipient_test.dart b/test/pages/send_view/save_recipient_test.dart new file mode 100644 index 0000000000..77e1fcc267 --- /dev/null +++ b/test/pages/send_view/save_recipient_test.dart @@ -0,0 +1,253 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/address.dart'; +import 'package:stackwallet/models/isar/models/contact_entry.dart'; +import 'package:stackwallet/pages/send_view/save_recipient.dart'; +import 'package:stackwallet/utilities/amount/amount.dart'; +import 'package:stackwallet/wallets/models/tx_data.dart'; + +void main() { + group('savableRecipientAddress', () { + test('returns one unique non-change recipient', () { + final txData = TxData( + recipients: [ + _recipient(' change ', isChange: true), + _recipient(' recipient '), + ], + sparkRecipients: [ + ( + address: 'recipient', + amount: Amount.zero, + memo: '', + isChange: false, + ), + ], + ); + + expect(_address(txData), 'recipient'); + }); + + test('collapses several outputs paying the same address', () { + expect( + _address( + TxData( + recipients: [ + _recipient('recipient'), + _recipient('recipient'), + _recipient('change', isChange: true), + ], + ), + ), + 'recipient', + ); + }); + + test('rejects empty and multiple-recipient transactions', () { + expect(_address(TxData()), isNull); + expect(_address(TxData(recipients: [])), isNull); + expect( + _address(TxData(recipients: [_recipient('one'), _recipient('two')])), + isNull, + ); + expect( + _address( + TxData( + recipients: [_recipient('transparent')], + sparkRecipients: [ + ( + address: 'spark', + amount: Amount.zero, + memo: '', + isChange: false, + ), + ], + ), + ), + isNull, + ); + }); + + test('rejects change-only and blank recipients', () { + expect( + _address(TxData(recipients: [_recipient('change', isChange: true)])), + isNull, + ); + expect(_address(TxData(recipients: [_recipient(' ')])), isNull); + }); + + test('rejects trade and PayNym flows', () { + final txData = TxData(recipients: [_recipient('recipient')]); + + expect(_address(txData, isTrade: true), isNull); + expect(_address(txData, isPaynym: true), isNull); + expect(_address(txData, isPaynymNotification: true), isNull); + }); + + test('rejects fee bumps of an already sent transaction', () { + expect( + _address(TxData(recipients: [_recipient('recipient')]), isRbf: true), + isNull, + ); + }); + + test('rejects a Salvium stake, whose recipient is the sending wallet', () { + expect( + _address( + TxData(recipients: [_recipient('own address')], salviumStakeTx: true), + ), + isNull, + ); + }); + }); + + group('SaveRecipientOption', () { + test('is off until the user opts in', () { + final option = SaveRecipientOption(address: 'recipient'); + + expect(option.isOffered, isTrue); + expect(option.enabled, isFalse); + expect(option.addressToSave, isNull); + + option.enabled = true; + + expect(option.addressToSave, 'recipient'); + }); + + test('has nothing to offer or save for an ineligible transaction', () { + final option = SaveRecipientOption(address: null); + + expect(option.isOffered, isFalse); + expect(option.addressToSave, isNull); + + option.enabled = true; + + expect(option.addressToSave, isNull); + }); + }); + + group('saveRecipient', () { + test('does not duplicate an address for the same coin', () async { + var addCalls = 0; + final outcome = await saveRecipient( + address: 'address', + coinIdentifier: 'bitcoin', + name: 'Name', + existingContacts: [_contact('bitcoin', 'address')], + addContact: (_) async { + addCalls++; + return true; + }, + ); + + expect(outcome.result, SaveRecipientResult.alreadySaved); + expect(addCalls, 0); + }); + + test('saves the same address again under a different coin', () async { + ContactEntry? saved; + final outcome = await saveRecipient( + address: 'address', + coinIdentifier: 'litecoin', + name: 'Name', + existingContacts: [_contact('bitcoin', 'address')], + addContact: (contact) async { + saved = contact; + return true; + }, + ); + + expect(outcome.result, SaveRecipientResult.saved); + expect(saved!.addresses.single.coinName, 'litecoin'); + }); + + test('normalizes the fallback name and contact fields', () async { + ContactEntry? saved; + final outcome = await saveRecipient( + address: 'address', + coinIdentifier: 'bitcoin', + name: ' ', + existingContacts: const [], + addContact: (contact) async { + saved = contact; + return true; + }, + ); + + expect(outcome.result, SaveRecipientResult.saved); + expect(saved!.name, 'Saved recipient'); + expect(saved!.addresses.single.address, 'address'); + expect(saved!.addresses.single.coinName, 'bitcoin'); + expect(saved!.addresses.single.label, 'Sent to'); + }); + + test('reports a rejected write as failed', () async { + final outcome = await saveRecipient( + address: 'address', + coinIdentifier: 'bitcoin', + name: 'Name', + existingContacts: const [], + addContact: (_) async => false, + ); + + expect(outcome.result, SaveRecipientResult.failed); + }); + + test('contains persistence failures after broadcast', () async { + final async = await saveRecipient( + address: 'address', + coinIdentifier: 'bitcoin', + name: 'Name', + existingContacts: const [], + addContact: (_) => Future.error(StateError('write failed')), + ); + + expect(async.result, SaveRecipientResult.failed); + expect(async.error, isA()); + + final sync = await saveRecipient( + address: 'address', + coinIdentifier: 'bitcoin', + name: 'Name', + existingContacts: const [], + addContact: (_) => throw StateError('write failed'), + ); + + expect(sync.result, SaveRecipientResult.failed); + expect(sync.error, isA()); + }); + }); +} + +String? _address( + TxData txData, { + bool isTrade = false, + bool isPaynym = false, + bool isPaynymNotification = false, + bool isRbf = false, +}) => savableRecipientAddress( + txData: txData, + isTradeTransaction: isTrade, + isPaynymTransaction: isPaynym, + isPaynymNotificationTransaction: isPaynymNotification, + isRbfTransaction: isRbf, +); + +TxRecipient _recipient(String address, {bool isChange = false}) => TxRecipient( + address: address, + amount: Amount.zero, + isChange: isChange, + addressType: AddressType.unknown, +); + +ContactEntry _contact(String coin, String address) { + final entry = ContactAddressEntry() + ..coinName = coin + ..address = address + ..label = 'label' + ..other = null; + return ContactEntry( + name: 'name', + addresses: [entry], + isFavorite: false, + customId: 'id', + ); +}