From 81b0f4dc681d733299daa77ef54c1b6ea803913d Mon Sep 17 00:00:00 2001 From: Waozi Date: Mon, 5 Oct 2026 16:30:46 -0300 Subject: [PATCH] Support multi-recipient Monero payment URIs Monero payment URIs can list several recipients, e.g. `monero:A;B?tx_amount=1;2&recipient_name=x;y`. Parse these into PaymentUriData.recipients, requiring one amount (and, when given, one name) per address. Callers opt in with allowMultipleRecipients, so screens that handle a single address reject these URIs rather than dropping recipients. Monero wallets now report supportsMultiRecipient. On their mobile and desktop send screens, "Add recipient" or a multi-recipient URI replaces the address field with the Recipient forms started for FROST, one per recipient, which can be added to and removed. A multi-recipient URI scanned or pasted into one of those forms asks before replacing all the recipients. A multi-recipient URI rejected for its amounts clears the previous recipient and amount, so Preview can't send them instead. The amount field shows their total, and removing all but one recipient returns to the normal form. The transaction has one output per recipient, and the confirmation screen lists each recipient with its name and amount. The Recipient form now: - shows the payment request's name for its address, and flags an invalid address; - fills in the address, amount, and name from a pasted payment request, as it did from a scanned one; - scans with the desktop QR code scanner on desktop; - keeps its amount, rather than the number typed, when the display unit changes, which otherwise read e.g. 1000 mXMR as 1000 XMR. The send screens' amount field does the same, keeping any Xelis send all. Recipient forms are kept per wallet, so recipients entered for one wallet never appear in another wallet's form. prepareSend no longer treats a multi-recipient total equal to the whole balance as sending the whole balance, which it then rejected for multiple recipients; the fee has to fit in the balance as for any other amount. --- .../send_view/confirm_transaction_view.dart | 121 ++++--- .../send_view/frost_ms/frost_send_view.dart | 11 +- lib/pages/send_view/frost_ms/recipient.dart | 200 ++++++++---- lib/pages/send_view/send_view.dart | 238 +++++++++++++- .../sub_widgets/multi_recipient.dart | 231 +++++++++++++ .../wallet_view/sub_widgets/desktop_send.dart | 251 ++++++++++++-- lib/utilities/address_utils.dart | 141 ++++++-- lib/wallets/wallet/impl/monero_wallet.dart | 3 + .../intermediate/lib_monero_wallet.dart | 12 +- test/address_utils_test.dart | 115 +++++++ .../send_view/frost_ms/recipient_test.dart | 309 ++++++++++++++++++ .../sub_widgets/multi_recipient_test.dart | 269 +++++++++++++++ 12 files changed, 1733 insertions(+), 168 deletions(-) create mode 100644 lib/pages/send_view/sub_widgets/multi_recipient.dart create mode 100644 test/pages/send_view/frost_ms/recipient_test.dart create mode 100644 test/pages/send_view/sub_widgets/multi_recipient_test.dart diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index 0bd3081323..e26f8c96ce 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -37,9 +37,7 @@ import '../../utilities/constants.dart'; import '../../utilities/logger.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; -import '../../wallets/crypto_currency/coins/epiccash.dart'; -import '../../wallets/crypto_currency/coins/ethereum.dart'; -import '../../wallets/crypto_currency/coins/mimblewimblecoin.dart'; +import '../../wallets/crypto_currency/crypto_currency.dart'; import '../../wallets/crypto_currency/intermediate/nano_currency.dart'; import '../../wallets/isar/providers/eth/current_token_wallet_provider.dart'; import '../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; @@ -67,6 +65,7 @@ import '../../wl_gen/interfaces/libepiccash_interface.dart'; import '../pinpad_views/lock_screen_view.dart'; import '../wallet_view/wallet_view.dart'; import 'sub_widgets/epic_slatepack_dialog.dart'; +import 'sub_widgets/multi_recipient.dart'; import 'sub_widgets/mwc_slatepack_dialog.dart'; import 'sub_widgets/sending_transaction_dialog.dart'; import 'sub_widgets/split_transaction_warning.dart'; @@ -83,6 +82,7 @@ class ConfirmTransactionView extends ConsumerStatefulWidget { this.isPaynymNotificationTransaction = false, this.isTokenTx = false, this.onSuccessInsteadOfRouteOnSuccess, + this.recipientLabels, }); static const String routeName = "/confirmTransactionView"; @@ -97,6 +97,10 @@ class ConfirmTransactionView extends ConsumerStatefulWidget { final VoidCallback? onSuccessInsteadOfRouteOnSuccess; final VoidCallback onSuccess; + /// The payment request's names for the recipients of a transaction with + /// several recipients, in order. + final List? recipientLabels; + @override ConsumerState createState() => _ConfirmTransactionViewState(); @@ -623,6 +627,11 @@ class _ConfirmTransactionViewState @override Widget build(BuildContext context) { final coin = ref.watch(pWalletCoin(walletId)); + final sentRecipients = [ + for (final e in widget.txData.recipients ?? []) + if (!e.isChange) e, + ]; + final hasMultipleRecipients = sentRecipients.length > 1; final String unit; final wallet = ref.watch(pWallets).getWallet(walletId); @@ -767,25 +776,36 @@ class _ConfirmTransactionViewState Text( widget.isPaynymTransaction ? "PayNym recipient" + : hasMultipleRecipients + ? "Recipients" : "Recipient", style: STextStyles.smallMed12(context), ), const SizedBox(height: 4), - Text( - widget.isPaynymTransaction - ? widget.txData.paynymAccountLite!.nymName - : widget - .txData - .recipients - ?.firstOrNull - ?.address ?? - widget - .txData - .sparkRecipients! - .first - .address, - style: STextStyles.itemSubtitle12(context), - ), + if (hasMultipleRecipients) + RecipientAmountList( + coin: coin, + recipients: sentRecipients, + labels: widget.recipientLabels, + labelStyle: STextStyles.smallMed12(context), + valueStyle: STextStyles.itemSubtitle12(context), + ) + else + Text( + widget.isPaynymTransaction + ? widget.txData.paynymAccountLite!.nymName + : widget + .txData + .recipients + ?.firstOrNull + ?.address ?? + widget + .txData + .sparkRecipients! + .first + .address, + style: STextStyles.itemSubtitle12(context), + ), ], ), ), @@ -1120,29 +1140,48 @@ class _ConfirmTransactionViewState ), ), const SizedBox(height: 2), - SelectableText( - // TODO: [prio=med] spark transaction specifics - better handling - widget.isPaynymTransaction - ? widget.txData.paynymAccountLite!.nymName - : widget - .txData - .recipients - ?.firstOrNull - ?.address ?? - widget - .txData - .sparkRecipients! - .first - .address, - style: - STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context) - .extension()! - .textDark, - ), - ), + if (hasMultipleRecipients) + RecipientAmountList( + coin: coin, + recipients: sentRecipients, + labels: widget.recipientLabels, + labelStyle: + STextStyles.desktopTextExtraExtraSmall( + context, + ), + valueStyle: + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of(context) + .extension()! + .textDark, + ), + ) + else + SelectableText( + // TODO: [prio=med] spark transaction specifics - better handling + widget.isPaynymTransaction + ? widget.txData.paynymAccountLite!.nymName + : widget + .txData + .recipients + ?.firstOrNull + ?.address ?? + widget + .txData + .sparkRecipients! + .first + .address, + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of(context) + .extension()! + .textDark, + ), + ), ], ), ), diff --git a/lib/pages/send_view/frost_ms/frost_send_view.dart b/lib/pages/send_view/frost_ms/frost_send_view.dart index ce67095ce5..2c7fd7b3e7 100644 --- a/lib/pages/send_view/frost_ms/frost_send_view.dart +++ b/lib/pages/send_view/frost_ms/frost_send_view.dart @@ -83,7 +83,7 @@ class _FrostSendViewState extends ConsumerState { final wallet = ref.read(pWallets).getWallet(walletId) as BitcoinFrostWallet; final recipients = recipientWidgetIndexes - .map((i) => ref.read(pRecipient(i).state).state) + .map((i) => ref.read(pRecipient((walletId: walletId, index: i)))) .map( (e) => TxRecipient( address: e!.address, @@ -190,7 +190,7 @@ class _FrostSendViewState extends ConsumerState { bool _validateRecipientFormStatesHelper() { for (final i in recipientWidgetIndexes) { - final state = ref.read(pRecipient(i)); + final state = ref.read(pRecipient((walletId: walletId, index: i))); if (state?.amount == null || state?.address == null || state!.address.isEmpty) { @@ -376,6 +376,7 @@ class _FrostSendViewState extends ConsumerState { ), child: Recipient( key: Key("recipientKey_${recipientWidgetIndexes[i]}"), + walletId: walletId, index: recipientWidgetIndexes[i], displayNumber: i + 1, coin: coin, @@ -387,8 +388,10 @@ class _FrostSendViewState extends ConsumerState { : () { ref .read( - pRecipient(recipientWidgetIndexes[i]) - .notifier, + pRecipient(( + walletId: walletId, + index: recipientWidgetIndexes[i], + )).notifier, ) .state = null; diff --git a/lib/pages/send_view/frost_ms/recipient.dart b/lib/pages/send_view/frost_ms/recipient.dart index d7bc33015c..9e65d81cc7 100644 --- a/lib/pages/send_view/frost_ms/recipient.dart +++ b/lib/pages/send_view/frost_ms/recipient.dart @@ -17,6 +17,7 @@ import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; import '../../../wallets/crypto_currency/crypto_currency.dart'; import '../../../widgets/custom_buttons/blue_text_button.dart'; +import '../../../widgets/desktop/qr_code_scanner_dialog.dart'; import '../../../widgets/icon_widgets/clipboard_icon.dart'; import '../../../widgets/icon_widgets/qrcode_icon.dart'; import '../../../widgets/icon_widgets/x_icon.dart'; @@ -31,14 +32,21 @@ import '../../../widgets/textfield_icon_button.dart'; // ); // }); -final pRecipient = - StateProvider.family<({String address, Amount? amount})?, int>( - (ref, index) => null, - ); +/// A recipient entered on a send screen. [label] is a payment request's +/// name for [address]. +typedef RecipientData = ({String address, Amount? amount, String? label}); + +/// A recipient form: the sending wallet and the form's index on its screen. +typedef RecipientId = ({String walletId, int index}); + +final pRecipient = StateProvider.family( + (ref, id) => null, +); class Recipient extends ConsumerStatefulWidget { const Recipient({ super.key, + required this.walletId, required this.index, required this.displayNumber, required this.coin, @@ -46,8 +54,10 @@ class Recipient extends ConsumerStatefulWidget { this.onChanged, required this.addAnotherRecipientTapped, required this.sendAllTapped, + this.onMultiRecipientUri, }); + final String walletId; final int index; final int displayNumber; final CryptoCurrency coin; @@ -57,6 +67,10 @@ class Recipient extends ConsumerStatefulWidget { final VoidCallback addAnotherRecipientTapped; final String Function() sendAllTapped; + /// Called with a scanned or pasted payment request for several recipients, + /// which are rejected if this is null. + final void Function(PaymentUriData paymentData)? onMultiRecipientUri; + @override ConsumerState createState() => _RecipientState(); } @@ -70,27 +84,87 @@ class _RecipientState extends ConsumerState { bool get isSingle => widget.remove == null; - void _updateRecipientData() { + RecipientId get _id => (walletId: widget.walletId, index: widget.index); + + void _updateRecipientData({String? label}) { final address = addressController.text; final amount = ref .read(pAmountFormatter(widget.coin)) .tryParseEditable(amountController.text); + final previous = ref.read(pRecipient(_id)); - ref.read(pRecipient(widget.index).notifier).state = ( + ref.read(pRecipient(_id).notifier).state = ( address: address, amount: amount, + // A payment request's name only applies to the address it gave. + label: label ?? (previous?.address == address ? previous?.label : null), ); widget.onChanged?.call(); } + /// Fills in the address, and any amount and name, from scanned, pasted, or + /// typed [input], which may be a payment request. + void _applyAddressInput(String input) { + final paymentData = AddressUtils.parsePaymentUri( + input, + logging: Logging.instance, + allowMultipleRecipients: widget.onMultiRecipientUri != null, + ); + + if (paymentData != null && + paymentData.coin?.uriScheme == widget.coin.uriScheme) { + if (paymentData.isMultiRecipient) { + // Keep this recipient as it was, and let the screen offer to replace + // every recipient with the request's. + addressController.text = ref.read(pRecipient(_id))?.address ?? ""; + setState(() { + _addressIsEmpty = addressController.text.isEmpty; + }); + widget.onMultiRecipientUri!(paymentData); + return; + } + + addressController.text = paymentData.address.trim(); + + if (paymentData.amount != null) { + final amount = Amount.tryParseCanonicalAmount( + paymentData.amount!, + fractionDigits: widget.coin.fractionDigits, + truncateOverprecision: true, + ); + if (amount != null) { + amountController.text = ref + .read(pAmountFormatter(widget.coin)) + .formatEditable(amount); + } else { + amountController.clear(); + } + } + + setState(() { + _addressIsEmpty = addressController.text.isEmpty; + }); + _updateRecipientData(label: paymentData.label); + } else { + if (addressController.text != input) { + addressController.text = input; + } + + setState(() { + _addressIsEmpty = addressController.text.isEmpty; + }); + _updateRecipientData(); + } + } + void _cryptoAmountChanged() async { if (!_cryptoAmountChangeLock) { Amount? cryptoAmount = ref .read(pAmountFormatter(widget.coin)) .tryParseEditable(amountController.text); if (cryptoAmount != null) { - if (ref.read(pRecipient(widget.index))?.amount != null && - ref.read(pRecipient(widget.index))?.amount == cryptoAmount) { + if (ref.read(pRecipient(_id))?.amount != null && + ref.read(pRecipient(_id))?.amount == cryptoAmount) { return; } @@ -121,49 +195,25 @@ class _RecipientState extends ConsumerState { await Future.delayed(const Duration(milliseconds: 75)); } - final qrResult = await ref.read(pBarcodeScanner).scan(context: context); - - Logging.instance.d("qrResult content: ${qrResult.rawContent}"); - - if (qrResult.rawContent == null) return; - - final paymentData = AddressUtils.parsePaymentUri( - qrResult.rawContent!, - logging: Logging.instance, - ); + if (Util.isDesktop) { + if (!mounted) return; + final qrCodeData = await showDialog( + context: context, + builder: (context) => const QrCodeScannerDialog(), + ); + if (qrCodeData == null || !mounted) return; - Logging.instance.d("qrResult parsed: $paymentData"); + _applyAddressInput(qrCodeData.trim()); + return; + } - if (paymentData != null && - paymentData.coin?.uriScheme == widget.coin.uriScheme) { - // auto fill address + final qrResult = await ref.read(pBarcodeScanner).scan(context: context); - addressController.text = paymentData.address.trim(); + Logging.instance.d("qrResult content: ${qrResult.rawContent}"); - // autofill amount field - if (paymentData.amount != null) { - final amount = Amount.tryParseCanonicalAmount( - paymentData.amount!, - fractionDigits: widget.coin.fractionDigits, - truncateOverprecision: true, - ); - if (amount != null) { - amountController.text = ref - .read(pAmountFormatter(widget.coin)) - .formatEditable(amount); - } else { - amountController.clear(); - } - } - } else { - addressController.text = qrResult.rawContent!.trim(); - } + if (qrResult.rawContent == null || !mounted) return; - setState(() { - _addressIsEmpty = addressController.text.isEmpty; - }); - - _updateRecipientData(); + _applyAddressInput(qrResult.rawContent!.trim()); } on PlatformException catch (e, s) { if (mounted) { try { @@ -195,13 +245,13 @@ class _RecipientState extends ConsumerState { amountController = TextEditingController(); // baseController = TextEditingController(); - final amount = ref.read(pRecipient(widget.index))?.amount; + final amount = ref.read(pRecipient(_id))?.amount; if (amount != null) { amountController.text = ref .read(pAmountFormatter(widget.coin)) .formatEditable(amount); } - addressController.text = ref.read(pRecipient(widget.index))?.address ?? ""; + addressController.text = ref.read(pRecipient(_id))?.address ?? ""; _addressIsEmpty = addressController.text.isEmpty; @@ -235,6 +285,19 @@ class _RecipientState extends ConsumerState { localeServiceChangeNotifierProvider.select((value) => value.locale), ); listenForAmountRelocalization(ref.listen, controllers: [amountController]); + // Keep the amount, rather than the number typed, when the unit changes. + ref.listen(pAmountFormatter(widget.coin), ( + previous, + next, + ) { + final amount = ref.read(pRecipient(_id))?.amount; + if (previous?.unit != next.unit && amount != null) { + amountController.text = next.formatEditable(amount); + } + }); + + final label = ref.watch(pRecipient(_id).select((e) => e?.label)); + final address = addressController.text.trim(); return RoundedContainer( color: Colors.transparent, @@ -246,10 +309,15 @@ class _RecipientState extends ConsumerState { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - isSingle ? "Send to" : "Recipient ${widget.displayNumber}", - style: STextStyles.smallMed12(context), - textAlign: TextAlign.left, + Flexible( + child: Text( + isSingle + ? "Send to" + : label ?? "Recipient ${widget.displayNumber}", + style: STextStyles.smallMed12(context), + textAlign: TextAlign.left, + overflow: TextOverflow.ellipsis, + ), ), CustomTextButton( text: isSingle ? "Add another recipient" : "Remove", @@ -272,7 +340,14 @@ class _RecipientState extends ConsumerState { enableSuggestions: false, focusNode: addressFocusNode, style: STextStyles.field(context), - onChanged: (_) { + onChanged: (newValue) { + final previous = ref.read(pRecipient(_id))?.address ?? ""; + // More than one character at once was pasted, and may be a + // payment request. + if ((newValue.length - previous.length).abs() > 1) { + _applyAddressInput(newValue.trim()); + return; + } _updateRecipientData(); setState(() { _addressIsEmpty = addressController.text.isEmpty; @@ -334,14 +409,7 @@ class _RecipientState extends ConsumerState { ); } - addressController.text = content.trim(); - - setState(() { - _addressIsEmpty = - addressController.text.isEmpty; - }); - - _updateRecipientData(); + _applyAddressInput(content.trim()); } }, child: _addressIsEmpty @@ -364,6 +432,16 @@ class _RecipientState extends ConsumerState { ), ), ), + if (address.isNotEmpty && !widget.coin.validateAddress(address)) + Padding( + padding: const EdgeInsets.only(left: 12, top: 4), + child: Text( + "Invalid address", + style: STextStyles.label(context).copyWith( + color: Theme.of(context).extension()!.textError, + ), + ), + ), SizedBox(height: isSingle ? 12 : 8), if (isSingle) Row( diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 34dbc6d081..5f51698f29 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -24,6 +24,7 @@ import '../../models/isar/models/isar_models.dart'; import '../../models/mwc_slatepack_models.dart'; import '../../models/paynym/paynym_account_lite.dart'; import '../../models/send_view_auto_fill_data.dart'; +import '../../notifications/show_flush_bar.dart'; import '../../providers/providers.dart'; import '../../providers/ui/fee_rate_type_state_provider.dart'; import '../../providers/ui/preview_tx_button_state_provider.dart'; @@ -87,6 +88,8 @@ import 'confirm_transaction_view.dart'; import 'sub_widgets/building_transaction_dialog.dart'; import 'sub_widgets/dual_balance_selection_sheet.dart'; import 'sub_widgets/epic_slatepack_dialog.dart'; +import 'frost_ms/recipient.dart'; +import 'sub_widgets/multi_recipient.dart'; import 'sub_widgets/mwc_slatepack_dialog.dart'; import 'sub_widgets/transaction_fee_selection_sheet.dart'; @@ -148,6 +151,11 @@ class _SendViewState extends ConsumerState { Amount? _cachedAmountToSend; String? _address; + /// The [pRecipient] index of each recipient's form while sending to more + /// than one recipient, otherwise null. + List? _recipientIndexes; + int _greatestRecipientIndex = 0; + bool _addressToggleFlag = false; bool _isFiroExWarningDisplayed = false; @@ -157,8 +165,161 @@ class _SendViewState extends ConsumerState { Set selectedUTXOs = {}; + bool get _supportsMultiRecipient => + ref.read(pWallets).getWallet(walletId).supportsMultiRecipient; + + RecipientId _recipientId(int index) => (walletId: walletId, index: index); + + /// The recipients while sending to more than one, otherwise null. + List? get _multiRecipients => _recipientIndexes + ?.map( + (index) => + ref.read(pRecipient(_recipientId(index))) ?? + (address: "", amount: null, label: null), + ) + .toList(); + + /// Applies a payment request for several recipients scanned, pasted, or + /// typed into the address field. + void _applyMultiRecipientUri(PaymentUriData paymentData) { + final recipients = parseUriRecipients(paymentData, coin); + if (recipients == null) { + // Clear the previous payment, so Preview can't send it in place of the + // rejected request. + _address = ""; + sendToController.text = ""; + cryptoAmountController.text = ""; + ref.read(pSendAmount.notifier).state = null; + _setValidAddressProviders(_address); + setState(() { + _addressToggleFlag = false; + }); + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Invalid payment request amounts", + context: context, + ); + return; + } + + if (paymentData.message != null) { + noteController.text = paymentData.message!; + } + _setOpReturnData(null); + _setMultiRecipients(recipients); + } + + /// Offers to replace the recipients with those of a payment request for + /// several recipients scanned or pasted into one recipient's form. + Future _replaceMultiRecipients(PaymentUriData paymentData) async { + final recipients = parseUriRecipients(paymentData, coin); + if (recipients == null) { + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Invalid payment request amounts", + context: context, + ); + return; + } + + final replace = await confirmReplaceRecipients( + context, + count: recipients.length, + ); + if (!replace || !mounted) { + return; + } + + if (paymentData.message != null) { + noteController.text = paymentData.message!; + } + _setMultiRecipients(recipients); + } + + void _addRecipientTapped() { + _setMultiRecipients([ + (address: _address ?? "", amount: ref.read(pSendAmount), label: null), + (address: "", amount: null, label: null), + ]); + } + + /// Replaces the address field, or any recipient forms, with a form for each + /// of [recipients]. + void _setMultiRecipients(List recipients) { + _clearRecipientForms(); + _recipientIndexes = [ + for (final recipient in recipients) _newRecipientForm(recipient), + ]; + _address = null; + sendToController.text = ""; + setState(() { + _addressToggleFlag = false; + }); + _multiRecipientsChanged(); + } + + int _newRecipientForm(RecipientData? recipient) { + final index = ++_greatestRecipientIndex; + ref.read(pRecipient(_recipientId(index)).notifier).state = recipient; + return index; + } + + void _clearRecipientForms() { + for (final index in _recipientIndexes ?? const []) { + ref.read(pRecipient(_recipientId(index)).notifier).state = null; + } + _recipientIndexes = null; + } + + void _addRecipientForm() { + setState(() { + _recipientIndexes!.add(_newRecipientForm(null)); + }); + _multiRecipientsChanged(); + } + + void _removeRecipientForm(int index) { + ref.read(pRecipient(_recipientId(index)).notifier).state = null; + _recipientIndexes!.remove(index); + + if (_recipientIndexes!.length == 1) { + // Back to the form for a single recipient. + final recipient = _multiRecipients!.single; + _clearRecipientForms(); + _address = recipient.address; + sendToController.text = recipient.address; + cryptoAmountController.text = recipient.amount == null + ? "" + : ref.read(pAmountFormatter(coin)).formatEditable(recipient.amount!); + ref.read(pSendAmount.notifier).state = recipient.amount; + setState(() { + _addressToggleFlag = recipient.address.isNotEmpty; + }); + _setValidAddressProviders(_address); + } else { + setState(() {}); + _multiRecipientsChanged(); + } + } + + /// Shows the total of the recipients' amounts as the amount to send. + void _multiRecipientsChanged() { + final total = _multiRecipients!.total; + cryptoAmountController.text = total == null + ? "" + : ref.read(pAmountFormatter(coin)).formatEditable(total); + ref.read(pSendAmount.notifier).state = total; + _setValidAddressProviders(_address); + } + void _applyUri(PaymentUriData paymentData) { try { + if (paymentData.isMultiRecipient) { + _applyMultiRecipientUri(paymentData); + return; + } + _clearRecipientForms(); + // auto fill address _address = paymentData.address.trim(); @@ -270,6 +431,7 @@ class _SendViewState extends ConsumerState { final paymentData = AddressUtils.parsePaymentUri( content, logging: Logging.instance, + allowMultipleRecipients: _supportsMultiRecipient, ); if (paymentData != null && @@ -338,6 +500,7 @@ class _SendViewState extends ConsumerState { final paymentData = AddressUtils.parsePaymentUri( qrResult.rawContent!, logging: Logging.instance, + allowMultipleRecipients: _supportsMultiRecipient, ); if (paymentData != null && @@ -509,7 +672,10 @@ class _SendViewState extends ConsumerState { late Amount _currentFee; void _setValidAddressProviders(String? address) { - if (isPaynymSend) { + if (_recipientIndexes != null) { + ref.read(pValidSendToAddress.notifier).state = _multiRecipients! + .isValidFor(coin); + } else if (isPaynymSend) { ref.read(pValidSendToAddress.notifier).state = true; } else { final wallet = ref.read(pWallets).getWallet(walletId); @@ -886,7 +1052,7 @@ class _SendViewState extends ConsumerState { coinControlEnabled && selectedUTXOs.isEmpty)) { // confirm send all - if (amount == availableBalance) { + if (_recipientIndexes == null && amount == availableBalance) { bool? shouldSendAll; if (mounted) { shouldSendAll = await showDialog( @@ -1095,14 +1261,18 @@ class _SendViewState extends ConsumerState { txDataFuture = wallet.prepareSend( txData: TxData( xelisSendAll: coin is Xelis && _xelisSendAll, - recipients: [ - TxRecipient( - address: _address!, - amount: amount, - isChange: false, - addressType: wallet.cryptoCurrency.getAddressType(_address!)!, - ), - ], + recipients: + _multiRecipients?.toTxRecipients(wallet.cryptoCurrency) ?? + [ + TxRecipient( + address: _address!, + amount: amount, + isChange: false, + addressType: wallet.cryptoCurrency.getAddressType( + _address!, + )!, + ), + ], memo: memo, feeRateType: feeRateType, satsPerVByte: satsPerVByte, @@ -1151,6 +1321,7 @@ class _SendViewState extends ConsumerState { txData: txData, walletId: walletId, isPaynymTransaction: isPaynymSend, + recipientLabels: _multiRecipients?.map((e) => e.label).toList(), onSuccess: () { if (mounted) { clearSendForm(); @@ -1216,6 +1387,7 @@ class _SendViewState extends ConsumerState { memoController.text = ""; _address = ""; _addressToggleFlag = false; + _clearRecipientForms(); _setOpReturnData(null); _setValidAddressProviders(""); setState(() {}); @@ -1447,6 +1619,16 @@ class _SendViewState extends ConsumerState { controllers: [cryptoAmountController, baseAmountController], onRelocalized: _cryptoAmountChanged, ); + // Keep the amount, rather than the number typed, when the unit changes. + ref.listen(pAmountFormatter(coin), (previous, next) { + final amount = ref.read(pSendAmount); + if (previous?.unit != next.unit && amount != null) { + // Only the display changes, so keep the send all intent. + final xelisSendAll = _xelisSendAll; + cryptoAmountController.text = next.formatEditable(amount); + _xelisSendAll = xelisSendAll; + } + }); final amountFormatter = ref.watch(pAmountFormatter(coin)); final balType = ref.watch(publicPrivateBalanceStateProvider); @@ -1721,6 +1903,13 @@ class _SendViewState extends ConsumerState { style: STextStyles.smallMed12(context), textAlign: TextAlign.left, ), + if (!isPaynymSend && + _recipientIndexes == null && + _supportsMultiRecipient) + CustomTextButton( + text: "Add recipient", + onTap: _addRecipientTapped, + ), // if (coin is Monero) // CustomTextButton( // text: "Use OpenAlias", @@ -1747,7 +1936,19 @@ class _SendViewState extends ConsumerState { readOnly: true, style: STextStyles.fieldLabel(context), ), - if (!isPaynymSend && !isSlatepackMode) + if (_recipientIndexes != null) + RecipientForms( + walletId: walletId, + coin: coin, + indexes: _recipientIndexes!, + onChanged: _multiRecipientsChanged, + onAdd: _addRecipientForm, + onRemove: _removeRecipientForm, + onMultiRecipientUri: _replaceMultiRecipients, + ), + if (!isPaynymSend && + !isSlatepackMode && + _recipientIndexes == null) ClipRRect( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, @@ -1780,6 +1981,8 @@ class _SendViewState extends ConsumerState { AddressUtils.parsePaymentUri( trimmed, logging: Logging.instance, + allowMultipleRecipients: + _supportsMultiRecipient, ); if (parsed != null) { _applyUri(parsed); @@ -1800,7 +2003,8 @@ class _SendViewState extends ConsumerState { _setValidAddressProviders(_address); setState(() { - _addressToggleFlag = newValue.isNotEmpty; + _addressToggleFlag = + sendToController.text.isNotEmpty; }); }, focusNode: _addressFocusNode, @@ -2211,11 +2415,15 @@ class _SendViewState extends ConsumerState { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - "Amount", + _recipientIndexes == null + ? "Amount" + : "Total", style: STextStyles.smallMed12(context), textAlign: TextAlign.left, ), - if (coin is! Ethereum && coin is! Tezos) + if (coin is! Ethereum && + coin is! Tezos && + _recipientIndexes == null) CustomTextButton( text: _getSendAllTitle( showCoinControl, @@ -2228,6 +2436,7 @@ class _SendViewState extends ConsumerState { ), const SizedBox(height: 8), TextField( + readOnly: _recipientIndexes != null, autocorrect: Util.isDesktop ? false : true, enableSuggestions: Util.isDesktop ? false : true, style: STextStyles.smallMed14(context).copyWith( @@ -2296,6 +2505,7 @@ class _SendViewState extends ConsumerState { const SizedBox(height: 8), if (Prefs.instance.externalCalls) TextField( + readOnly: _recipientIndexes != null, autocorrect: Util.isDesktop ? false : true, enableSuggestions: Util.isDesktop ? false diff --git a/lib/pages/send_view/sub_widgets/multi_recipient.dart b/lib/pages/send_view/sub_widgets/multi_recipient.dart new file mode 100644 index 0000000000..0f8db8167d --- /dev/null +++ b/lib/pages/send_view/sub_widgets/multi_recipient.dart @@ -0,0 +1,231 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../themes/stack_colors.dart'; +import '../../../utilities/address_utils.dart'; +import '../../../utilities/amount/amount.dart'; +import '../../../utilities/amount/amount_formatter.dart'; +import '../../../utilities/amount/amount_unit.dart'; +import '../../../utilities/util.dart'; +import '../../../wallets/crypto_currency/crypto_currency.dart'; +import '../../../wallets/models/tx_data.dart'; +import '../../../widgets/desktop/primary_button.dart'; +import '../../../widgets/desktop/secondary_button.dart'; +import '../../../widgets/dialogs/basic_dialog.dart'; +import '../frost_ms/recipient.dart'; + +/// Converts the recipients of [paymentData] to amounts of [coin]. +/// +/// Returns null if any recipient has a missing, invalid, or zero amount. +List? parseUriRecipients( + PaymentUriData paymentData, + CryptoCurrency coin, +) { + final List recipients = []; + for (final recipient in paymentData.recipients) { + final amount = recipient.amount == null + ? null + : Amount.tryParseCanonicalAmount( + recipient.amount!, + fractionDigits: coin.fractionDigits, + truncateOverprecision: true, + ); + if (amount == null || amount <= Amount.zero) { + return null; + } + recipients.add(( + address: recipient.address, + amount: amount, + label: recipient.label, + )); + } + return recipients; +} + +extension RecipientDataListExt on List { + /// The sum of the amounts, or null if any recipient is missing one. + Amount? get total { + Amount? sum; + for (final e in this) { + if (e.amount == null) { + return null; + } + sum = sum == null ? e.amount : sum + e.amount!; + } + return sum; + } + + /// Whether every recipient has a valid address and an amount above zero. + bool isValidFor(CryptoCurrency coin) => every( + (e) => + coin.validateAddress(e.address) && + e.amount != null && + e.amount! > Amount.zero, + ); + + /// One transaction output per recipient. Requires [isValidFor]. + List toTxRecipients(CryptoCurrency coin) => [ + for (final e in this) + TxRecipient( + address: e.address, + amount: e.amount!, + isChange: false, + addressType: coin.getAddressType(e.address)!, + ), + ]; +} + +/// Asks whether to replace the recipients on screen with the [count] +/// recipients of a scanned or pasted payment request. +Future confirmReplaceRecipients( + BuildContext context, { + required int count, +}) async { + final replace = await showDialog( + context: context, + builder: (context) => BasicDialog( + title: "Replace recipients?", + message: + "This payment request has $count recipients. Replace the " + "recipients you have entered with them?", + desktopHeight: double.infinity, + desktopWidth: 450, + leftButton: SecondaryButton( + label: "Cancel", + buttonHeight: Util.isDesktop ? ButtonHeight.l : null, + onPressed: () => Navigator.of(context).pop(false), + ), + rightButton: PrimaryButton( + label: "Replace", + buttonHeight: Util.isDesktop ? ButtonHeight.l : null, + onPressed: () => Navigator.of(context).pop(true), + ), + ), + ); + return replace ?? false; +} + +/// A form for each recipient of a transaction with several recipients, and a +/// button to add another. +class RecipientForms extends StatelessWidget { + const RecipientForms({ + super.key, + required this.walletId, + required this.coin, + required this.indexes, + required this.onChanged, + required this.onAdd, + required this.onRemove, + required this.onMultiRecipientUri, + }); + + final String walletId; + final CryptoCurrency coin; + + /// The [pRecipient] index of each recipient's form, in order. + final List indexes; + final VoidCallback onChanged; + final VoidCallback onAdd; + final void Function(int index) onRemove; + final void Function(PaymentUriData paymentData) onMultiRecipientUri; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + for (final (i, index) in indexes.indexed) + Padding( + key: Key("recipientKey_$index"), + padding: EdgeInsets.only(top: i == 0 ? 0 : 16), + child: Recipient( + walletId: walletId, + index: index, + displayNumber: i + 1, + coin: coin, + onChanged: onChanged, + remove: () => onRemove(index), + addAnotherRecipientTapped: onAdd, + // Send all is not offered with several recipients. + sendAllTapped: () => "", + onMultiRecipientUri: onMultiRecipientUri, + ), + ), + const SizedBox(height: 16), + SecondaryButton( + label: "Add recipient", + width: double.infinity, + buttonHeight: Util.isDesktop ? ButtonHeight.l : null, + onPressed: onAdd, + ), + ], + ); + } +} + +/// Each recipient's address and amount, separated by dividers, for the +/// confirmation screen. +class RecipientAmountList extends ConsumerWidget { + const RecipientAmountList({ + super.key, + required this.coin, + required this.recipients, + this.labels, + required this.labelStyle, + required this.valueStyle, + }); + + final CryptoCurrency coin; + final List recipients; + + /// The payment request's names for [recipients], in the same order. + final List? labels; + final TextStyle labelStyle; + final TextStyle valueStyle; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colors = Theme.of(context).extension()!; + final formatter = ref.watch(pAmountFormatter(coin)); + + final divider = Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Container(height: 1, color: colors.backgroundAppBar), + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + for (int i = 0; i < recipients.length; i++) ...[ + if (i > 0) divider, + Text( + (labels != null && i < labels!.length ? labels![i] : null) ?? + "Recipient ${i + 1}", + style: labelStyle, + ), + const SizedBox(height: 4), + SelectableText(recipients[i].address, style: valueStyle), + const SizedBox(height: 8), + Row( + children: [ + Text("Amount", style: labelStyle), + const SizedBox(width: 12), + Expanded( + child: SelectableText( + // Without trailing zeros, e.g. "1 XMR" rather than + // "1.000000000000 XMR". + "${formatter.formatEditable(recipients[i].amount)} " + "${formatter.unit.unitForCoin(coin)}", + style: valueStyle, + textAlign: TextAlign.right, + ), + ), + ], + ), + ], + ], + ); + } +} diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart index 66ebcbd460..e9cebc4383 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart @@ -24,9 +24,12 @@ import '../../../../models/isar/models/contact_entry.dart'; import '../../../../models/mwc_slatepack_models.dart'; import '../../../../models/paynym/paynym_account_lite.dart'; import '../../../../models/send_view_auto_fill_data.dart'; +import '../../../../notifications/show_flush_bar.dart'; import '../../../../pages/send_view/confirm_transaction_view.dart'; import '../../../../pages/send_view/sub_widgets/building_transaction_dialog.dart'; import '../../../../pages/send_view/sub_widgets/epic_slatepack_dialog.dart'; +import '../../../../pages/send_view/frost_ms/recipient.dart'; +import '../../../../pages/send_view/sub_widgets/multi_recipient.dart'; import '../../../../pages/send_view/sub_widgets/mwc_slatepack_dialog.dart'; import '../../../../pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart'; import '../../../../providers/providers.dart'; @@ -134,6 +137,11 @@ class _DesktopSendState extends ConsumerState { Amount? _cachedAmountToSend; String? _address; + /// The [pRecipient] index of each recipient's form while sending to more + /// than one recipient, otherwise null. + List? _recipientIndexes; + int _greatestRecipientIndex = 0; + bool _addressToggleFlag = false; bool _isFiroExWarningDisplayed = false; @@ -473,7 +481,7 @@ class _DesktopSendState extends ConsumerState { coinControlEnabled) || (coinControlEnabled && ref.read(desktopUseUTXOs).isEmpty)) { // confirm send all - if (amount == availableBalance) { + if (_recipientIndexes == null && amount == availableBalance) { final bool? shouldSendAll = await showDialog( context: context, useSafeArea: false, @@ -722,14 +730,18 @@ class _DesktopSendState extends ConsumerState { txDataFuture = wallet.prepareSend( txData: TxData( xelisSendAll: coin is Xelis && _xelisSendAll, - recipients: [ - TxRecipient( - address: _address!, - amount: amount, - isChange: false, - addressType: wallet.cryptoCurrency.getAddressType(_address!)!, - ), - ], + recipients: + _multiRecipients?.toTxRecipients(wallet.cryptoCurrency) ?? + [ + TxRecipient( + address: _address!, + amount: amount, + isChange: false, + addressType: wallet.cryptoCurrency.getAddressType( + _address!, + )!, + ), + ], memo: memo, feeRateType: feeRateType, satsPerVByte: satsPerVByte, @@ -785,6 +797,7 @@ class _DesktopSendState extends ConsumerState { onSuccess: clearSendForm, isPaynymTransaction: isPaynymSend, routeOnSuccessName: DesktopHomeView.routeName, + recipientLabels: _multiRecipients?.map((e) => e.label).toList(), ), ), ), @@ -868,6 +881,7 @@ class _DesktopSendState extends ConsumerState { nonceController.text = ""; _address = ""; _addressToggleFlag = false; + _clearRecipientForms(); _syncFeeAmount(null); _setOpReturnData(null); _setValidAddressProviders(""); @@ -949,6 +963,7 @@ class _DesktopSendState extends ConsumerState { final paymentData = AddressUtils.parsePaymentUri( qrCodeData, logging: Logging.instance, + allowMultipleRecipients: _supportsMultiRecipient, ); if (paymentData != null && @@ -975,7 +990,10 @@ class _DesktopSendState extends ConsumerState { } void _setValidAddressProviders(String? address) { - if (isPaynymSend) { + if (_recipientIndexes != null) { + ref.read(pValidSendToAddress.notifier).state = _multiRecipients! + .isValidFor(coin); + } else if (isPaynymSend) { ref.read(pValidSendToAddress.notifier).state = true; } else { final wallet = ref.read(pWallets).getWallet(walletId); @@ -1007,8 +1025,160 @@ class _DesktopSendState extends ConsumerState { } } + bool get _supportsMultiRecipient => + ref.read(pWallets).getWallet(walletId).supportsMultiRecipient; + + RecipientId _recipientId(int index) => (walletId: walletId, index: index); + + /// The recipients while sending to more than one, otherwise null. + List? get _multiRecipients => _recipientIndexes + ?.map( + (index) => + ref.read(pRecipient(_recipientId(index))) ?? + (address: "", amount: null, label: null), + ) + .toList(); + + /// Applies a payment request for several recipients scanned, pasted, or + /// typed into the address field. + void _applyMultiRecipientUri(PaymentUriData paymentData) { + final recipients = parseUriRecipients(paymentData, coin); + if (recipients == null) { + // Clear the previous payment, so Preview can't send it in place of the + // rejected request. + _address = ""; + sendToController.text = ""; + cryptoAmountController.text = ""; + ref.read(pSendAmount.notifier).state = null; + _syncFeeAmount(null); + _setValidAddressProviders(_address); + setState(() { + _addressToggleFlag = false; + }); + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Invalid payment request amounts", + context: context, + ); + return; + } + + _note = paymentData.message; + _setOpReturnData(null); + _setMultiRecipients(recipients); + } + + /// Offers to replace the recipients with those of a payment request for + /// several recipients scanned or pasted into one recipient's form. + Future _replaceMultiRecipients(PaymentUriData paymentData) async { + final recipients = parseUriRecipients(paymentData, coin); + if (recipients == null) { + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Invalid payment request amounts", + context: context, + ); + return; + } + + final replace = await confirmReplaceRecipients( + context, + count: recipients.length, + ); + if (!replace || !mounted) { + return; + } + + _note = paymentData.message; + _setMultiRecipients(recipients); + } + + void _addRecipientTapped() { + _setMultiRecipients([ + (address: _address ?? "", amount: ref.read(pSendAmount), label: null), + (address: "", amount: null, label: null), + ]); + } + + /// Replaces the address field, or any recipient forms, with a form for each + /// of [recipients]. + void _setMultiRecipients(List recipients) { + _clearRecipientForms(); + _recipientIndexes = [ + for (final recipient in recipients) _newRecipientForm(recipient), + ]; + _address = null; + sendToController.text = ""; + setState(() { + _addressToggleFlag = false; + }); + _multiRecipientsChanged(); + } + + int _newRecipientForm(RecipientData? recipient) { + final index = ++_greatestRecipientIndex; + ref.read(pRecipient(_recipientId(index)).notifier).state = recipient; + return index; + } + + void _clearRecipientForms() { + for (final index in _recipientIndexes ?? const []) { + ref.read(pRecipient(_recipientId(index)).notifier).state = null; + } + _recipientIndexes = null; + } + + void _addRecipientForm() { + setState(() { + _recipientIndexes!.add(_newRecipientForm(null)); + }); + _multiRecipientsChanged(); + } + + void _removeRecipientForm(int index) { + ref.read(pRecipient(_recipientId(index)).notifier).state = null; + _recipientIndexes!.remove(index); + + if (_recipientIndexes!.length == 1) { + // Back to the form for a single recipient. + final recipient = _multiRecipients!.single; + _clearRecipientForms(); + _address = recipient.address; + sendToController.text = recipient.address; + cryptoAmountController.text = recipient.amount == null + ? "" + : ref.read(pAmountFormatter(coin)).formatEditable(recipient.amount!); + ref.read(pSendAmount.notifier).state = recipient.amount; + _syncFeeAmount(recipient.amount); + setState(() { + _addressToggleFlag = recipient.address.isNotEmpty; + }); + _setValidAddressProviders(_address); + } else { + setState(() {}); + _multiRecipientsChanged(); + } + } + + /// Shows the total of the recipients' amounts as the amount to send. + void _multiRecipientsChanged() { + final total = _multiRecipients!.total; + cryptoAmountController.text = total == null + ? "" + : ref.read(pAmountFormatter(coin)).formatEditable(total); + ref.read(pSendAmount.notifier).state = total; + _syncFeeAmount(total); + _setValidAddressProviders(_address); + } + void _applyUri(PaymentUriData paymentData) { try { + if (paymentData.isMultiRecipient) { + _applyMultiRecipientUri(paymentData); + return; + } + _clearRecipientForms(); + // auto fill address _address = paymentData.address; sendToController.text = _address!; @@ -1102,6 +1272,7 @@ class _DesktopSendState extends ConsumerState { final paymentData = AddressUtils.parsePaymentUri( content, logging: Logging.instance, + allowMultipleRecipients: _supportsMultiRecipient, ); if (paymentData != null && paymentData.coin?.uriScheme == coin.uriScheme) { @@ -1377,6 +1548,16 @@ class _DesktopSendState extends ConsumerState { controllers: [cryptoAmountController, baseAmountController], onRelocalized: _cryptoAmountChanged, ); + // Keep the amount, rather than the number typed, when the unit changes. + ref.listen(pAmountFormatter(coin), (previous, next) { + final amount = ref.read(pSendAmount); + if (previous?.unit != next.unit && amount != null) { + // Only the display changes, so keep the send all intent. + final xelisSendAll = _xelisSendAll; + cryptoAmountController.text = next.formatEditable(amount); + _xelisSendAll = xelisSendAll; + } + }); final isCustomFee = ref.watch(feeRateTypeDesktopStateProvider).isCustom; // ethFee is checked in the ValueListenableBuilder around the preview // button so fee keystrokes don't rebuild this whole view. @@ -1633,7 +1814,7 @@ class _DesktopSendState extends ConsumerState { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - "Amount", + _recipientIndexes == null ? "Amount" : "Total", style: STextStyles.desktopTextExtraSmall(context).copyWith( color: Theme.of(context) .extension()! @@ -1641,7 +1822,9 @@ class _DesktopSendState extends ConsumerState { ), textAlign: TextAlign.left, ), - if (coin is! Ethereum && coin is! Tezos) + if (coin is! Ethereum && + coin is! Tezos && + _recipientIndexes == null) CustomTextButton( text: _getSendAllTitle( showCoinControl, @@ -1653,6 +1836,7 @@ class _DesktopSendState extends ConsumerState { ), const SizedBox(height: 10), TextField( + readOnly: _recipientIndexes != null, autocorrect: Util.isDesktop ? false : true, enableSuggestions: Util.isDesktop ? false : true, style: STextStyles.smallMed14(context).copyWith( @@ -1714,6 +1898,7 @@ class _DesktopSendState extends ConsumerState { if (Prefs.instance.externalCalls) const SizedBox(height: 10), if (Prefs.instance.externalCalls) TextField( + readOnly: _recipientIndexes != null, autocorrect: Util.isDesktop ? false : true, enableSuggestions: Util.isDesktop ? false : true, style: STextStyles.smallMed14(context).copyWith( @@ -1802,22 +1987,43 @@ class _DesktopSendState extends ConsumerState { if (!isPaynymSend && !((isMimblewimblecoin || isEpiccash) && ref.watch(pIsSlatepack(widget.walletId)))) - Text( - "Send to", - style: STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of(context) - .extension()! - .textFieldActiveSearchIconRight, - ), - textAlign: TextAlign.left, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Send to", + style: STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of(context) + .extension()! + .textFieldActiveSearchIconRight, + ), + textAlign: TextAlign.left, + ), + if (_recipientIndexes == null && _supportsMultiRecipient) + CustomTextButton( + text: "Add recipient", + onTap: _addRecipientTapped, + ), + ], ), if (!isPaynymSend && !((isMimblewimblecoin || isEpiccash) && ref.watch(pIsSlatepack(widget.walletId)))) const SizedBox(height: 10), + if (_recipientIndexes != null) + RecipientForms( + walletId: walletId, + coin: coin, + indexes: _recipientIndexes!, + onChanged: _multiRecipientsChanged, + onAdd: _addRecipientForm, + onRemove: _removeRecipientForm, + onMultiRecipientUri: _replaceMultiRecipients, + ), if (!isPaynymSend && !((isMimblewimblecoin || isEpiccash) && - ref.watch(pIsSlatepack(widget.walletId)))) + ref.watch(pIsSlatepack(widget.walletId))) && + _recipientIndexes == null) ClipRRect( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, @@ -1848,6 +2054,7 @@ class _DesktopSendState extends ConsumerState { final parsed = AddressUtils.parsePaymentUri( trimmed, logging: Logging.instance, + allowMultipleRecipients: _supportsMultiRecipient, ); if (parsed != null) { _setOpReturnData(parsed.additionalParams['op_return']); @@ -1867,7 +2074,7 @@ class _DesktopSendState extends ConsumerState { _setValidAddressProviders(_address); setState(() { - _addressToggleFlag = newValue.isNotEmpty; + _addressToggleFlag = sendToController.text.isNotEmpty; }); }, focusNode: _addressFocusNode, diff --git a/lib/utilities/address_utils.dart b/lib/utilities/address_utils.dart index cb57e48e81..ce92bdeb3b 100644 --- a/lib/utilities/address_utils.dart +++ b/lib/utilities/address_utils.dart @@ -72,7 +72,13 @@ class AddressUtils { } // Parse query parameters. - result.addAll(_parseQueryParameters(u.queryParameters)); + result.addAll( + _parseQueryParameters( + u.queryParameters, + // Monero URIs may list values for multiple recipients. + allowLists: result["scheme"] == "monero", + ), + ); // Handle Monero-specific fragment (tx_description). if (u.fragment.isNotEmpty && result["scheme"] == "monero") { @@ -92,7 +98,10 @@ class AddressUtils { } /// Helper method to parse and normalize query parameters. - static Map _parseQueryParameters(Map params) { + static Map _parseQueryParameters( + Map params, { + required bool allowLists, + }) { final Map result = {}; params.forEach((key, value) { final lowerKey = key.toLowerCase(); @@ -100,11 +109,12 @@ class AddressUtils { switch (lowerKey) { case 'amount': case 'tx_amount': - final normalized = _normalizeAmount(value); - if (normalized == null) { + final amounts = allowLists ? value.split(';') : [value]; + final normalized = amounts.map(_normalizeAmount).toList(); + if (normalized.contains(null)) { throw FormatException("Invalid payment URI amount: $value"); } - result['amount'] = normalized; + result['amount'] = normalized.join(';'); break; case 'label': case 'recipient_name': @@ -133,10 +143,62 @@ class AddressUtils { return RegExp(r'^(\d+(\.\d+)?|\.\d+)$').hasMatch(trimmed) ? trimmed : null; } + /// Splits a Monero URI's `;` separated addresses, amounts, and recipient + /// names into individual recipients. + static List _parseMoneroRecipients({ + required String address, + required String? amount, + required String? label, + }) { + final addresses = address.split(';').map((e) => e.trim()).toList(); + if (addresses.length == 1) { + if (amount?.contains(';') == true) { + throw FormatException("Invalid payment URI amount: $amount"); + } + return [ + PaymentUriRecipient(address: address, amount: amount, label: label), + ]; + } + + if (addresses.contains("")) { + throw FormatException("Empty address in payment URI: $address"); + } + + final amounts = amount?.split(';'); + if (amounts == null || amounts.length != addresses.length) { + throw const FormatException( + "Payment URI must have one amount per recipient", + ); + } + + final labels = label?.split(';'); + if (labels != null && labels.length != addresses.length) { + throw const FormatException( + "Payment URI must have one name per recipient", + ); + } + + return [ + for (int i = 0; i < addresses.length; i++) + PaymentUriRecipient( + address: addresses[i], + amount: amounts[i], + label: labels?[i].isNotEmpty == true ? labels![i] : null, + ), + ]; + } + /// Centralized method to handle various cryptocurrency URIs and return a common object. /// + /// Multi-recipient URIs are only returned when [allowMultipleRecipients] is + /// true, so callers that handle a single address never drop recipients. + /// /// Returns null on failure to parse - static PaymentUriData? parsePaymentUri(String uri, {Logging? logging}) { + static PaymentUriData? parsePaymentUri( + String uri, { + Logging? logging, + bool allowMultipleRecipients = false, + }) { // hacky check its not just a bcash, ecash, or xel address const cashAddrSchemes = {"bitcoincash", "bchtest", "ecash", "ectest"}; final parsedUri = Uri.tryParse(uri); @@ -158,13 +220,36 @@ class AddressUtils { // Filter out unrecognized parameters. final filteredParams = _filterParams(parsedData); + final amount = filteredParams['amount'] ?? filteredParams['tx_amount']; + final label = filteredParams['label'] ?? filteredParams['recipient_name']; + + final List recipients; + if (scheme == "monero") { + recipients = _parseMoneroRecipients( + address: address, + amount: amount, + label: label, + ); + if (recipients.length > 1 && !allowMultipleRecipients) { + throw const FormatException( + "Multiple recipients are not supported here", + ); + } + } else { + recipients = [ + PaymentUriRecipient( + address: cashAddrSchemes.contains(scheme) + ? "$scheme:$address".toLowerCase() + : address, + amount: amount, + label: label, + ), + ]; + } + return PaymentUriData( scheme: scheme, - address: cashAddrSchemes.contains(scheme) - ? "$scheme:$address".toLowerCase() - : address, - amount: filteredParams['amount'] ?? filteredParams['tx_amount'], - label: filteredParams['label'] ?? filteredParams['recipient_name'], + recipients: recipients, message: filteredParams['message'] ?? filteredParams['tx_description'], paymentId: filteredParams['tx_payment_id'], // Specific to Monero @@ -378,15 +463,37 @@ class AddressUtils { } } -class PaymentUriData { +class PaymentUriRecipient { final String address; - final String? scheme; final String? amount; final String? label; + + const PaymentUriRecipient({required this.address, this.amount, this.label}); + + @override + String toString() => + "PaymentUriRecipient { " + "address: $address, " + "amount: $amount, " + "label: $label" + " }"; +} + +class PaymentUriData { + final String? scheme; + + /// Always contains at least one recipient. + final List recipients; final String? message; final String? paymentId; // Specific to Monero. final Map additionalParams; + String get address => recipients.first.address; + String? get amount => recipients.first.amount; + String? get label => recipients.first.label; + + bool get isMultiRecipient => recipients.length > 1; + CryptoCurrency? get coin => AddressUtils._getCryptoCurrencyByScheme( scheme ?? "", // empty will just return null ); @@ -406,10 +513,8 @@ class PaymentUriData { } PaymentUriData({ - required this.address, + required this.recipients, this.scheme, - this.amount, - this.label, this.message, this.paymentId, required this.additionalParams, @@ -419,10 +524,8 @@ class PaymentUriData { String toString() => "PaymentUriData { " "coin: $coin, " - "address: $address, " - "amount: $amount, " + "recipients: $recipients, " "scheme: $scheme, " - "label: $label, " "message: $message, " "paymentId: $paymentId, " "additionalParams: $additionalParams" diff --git a/lib/wallets/wallet/impl/monero_wallet.dart b/lib/wallets/wallet/impl/monero_wallet.dart index 935d5ad3aa..c394169371 100644 --- a/lib/wallets/wallet/impl/monero_wallet.dart +++ b/lib/wallets/wallet/impl/monero_wallet.dart @@ -13,6 +13,9 @@ class MoneroWallet extends LibMoneroWallet { MoneroWallet(CryptoCurrencyNetwork network) : super(Monero(network), lib_monero_compat.WalletType.monero); + @override + bool get supportsMultiRecipient => true; + @override Future estimateFeeFor(Amount amount, BigInt feeRate) async { if (wallet == null || syncStatus is! lib_monero_compat.SyncedSyncStatus) { diff --git a/lib/wallets/wallet/intermediate/lib_monero_wallet.dart b/lib/wallets/wallet/intermediate/lib_monero_wallet.dart index 8d8b0372ee..afc68c318c 100644 --- a/lib/wallets/wallet/intermediate/lib_monero_wallet.dart +++ b/lib/wallets/wallet/intermediate/lib_monero_wallet.dart @@ -1332,7 +1332,11 @@ abstract class LibMoneroWallet try { final bool sweep; - if (txData.utxos == null) { + if (txData.recipients!.length > 1) { + // Paying several recipients is never a sweep, even when their + // total is the whole balance, as the fee must still be paid. + sweep = false; + } else if (txData.utxos == null) { final balance = await availableBalance; sweep = txData.amount! == balance; } else { @@ -1342,12 +1346,6 @@ abstract class LibMoneroWallet sweep = txData.amount!.raw == totalInputsValue; } - // TODO: test this one day - // cs_monero may not support this yet properly - if (sweep && txData.recipients!.length > 1) { - throw Exception("Send all not supported with multiple recipients"); - } - final List outputs = []; for (final recipient in txData.recipients!) { final output = CsRecipient(recipient.address, recipient.amount.raw); diff --git a/test/address_utils_test.dart b/test/address_utils_test.dart index dc1bd24e74..6475e27632 100644 --- a/test/address_utils_test.dart +++ b/test/address_utils_test.dart @@ -298,4 +298,119 @@ void main() { expect(parsed?.message, "Some kind of message!"); } }); + + group("Monero multi-recipient payment URIs", () { + const addressA = "4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx"; + const addressB = "8BnERTpvL5MbCLtj5n9No7J5oE5hHiB3tVCK5cjSvCsx"; + + test("parses addresses, amounts, and names per recipient", () { + final result = AddressUtils.parsePaymentUri( + "monero:$addressA;$addressB?tx_amount=1.5;0.25" + "&recipient_name=Alice;Bob&tx_description=Dinner", + allowMultipleRecipients: true, + ); + + expect(result, isNotNull); + expect(result!.isMultiRecipient, isTrue); + expect(result.recipients.map((e) => e.address), [addressA, addressB]); + expect(result.recipients.map((e) => e.amount), ["1.5", "0.25"]); + expect(result.recipients.map((e) => e.label), ["Alice", "Bob"]); + expect(result.message, "Dinner"); + expect(result.address, addressA); + expect(result.amount, "1.5"); + }); + + test("names are optional", () { + final result = AddressUtils.parsePaymentUri( + "monero:$addressA;$addressB?tx_amount=1;2", + allowMultipleRecipients: true, + ); + + expect(result!.recipients.map((e) => e.label), [null, null]); + }); + + test("parses percent-encoded lists", () { + final result = AddressUtils.parsePaymentUri( + "monero:$addressA;$addressB?recipient_name=Page%3BTips" + "&tx_amount=0.09%3B0.01&tx_description=Great%20stream", + allowMultipleRecipients: true, + ); + + expect(result!.recipients.map((e) => e.address), [addressA, addressB]); + expect(result.recipients.map((e) => e.amount), ["0.09", "0.01"]); + expect(result.recipients.map((e) => e.label), ["Page", "Tips"]); + expect(result.message, "Great stream"); + }); + + test("parses more than two recipients", () { + const addressC = "4C7oeS1rq4w7TxjdBdz2Z6rxDBsJtUpDjN9X8fKCnQsx"; + final result = AddressUtils.parsePaymentUri( + "monero:$addressA;$addressB;$addressC?tx_amount=1;.5;0.000000000001" + "&recipient_name=Alice;;Carol", + allowMultipleRecipients: true, + ); + + expect(result!.recipients.map((e) => e.address), [ + addressA, + addressB, + addressC, + ]); + expect(result.recipients.map((e) => e.amount), [ + "1", + ".5", + "0.000000000001", + ]); + expect(result.recipients.map((e) => e.label), ["Alice", null, "Carol"]); + }); + + test("is rejected unless multiple recipients are allowed", () { + expect( + AddressUtils.parsePaymentUri( + "monero:$addressA;$addressB?tx_amount=1;2", + ), + isNull, + ); + }); + + test("rejects malformed recipient lists", () { + for (final uri in [ + "monero:$addressA;$addressB", + "monero:$addressA;$addressB?tx_amount=1", + "monero:$addressA;$addressB?tx_amount=1;2;3", + "monero:$addressA;$addressB?tx_amount=1;abc", + "monero:$addressA;$addressB?tx_amount=1;", + "monero:$addressA;$addressB?tx_amount=1;2&recipient_name=Alice", + "monero:$addressA;?tx_amount=1;2", + "monero:$addressA?tx_amount=1;2", + ]) { + expect( + AddressUtils.parsePaymentUri(uri, allowMultipleRecipients: true), + isNull, + reason: uri, + ); + } + }); + + test("single recipient URIs are unchanged", () { + final result = AddressUtils.parsePaymentUri( + "monero:$addressA?tx_amount=1.5&recipient_name=Alice;Co", + allowMultipleRecipients: true, + ); + + expect(result!.isMultiRecipient, isFalse); + expect(result.address, addressA); + expect(result.amount, "1.5"); + expect(result.label, "Alice;Co"); + }); + + test("other schemes do not split recipients", () { + expect( + AddressUtils.parsePaymentUri( + "wownero:$addressA;$addressB?tx_amount=1;2", + allowMultipleRecipients: true, + ), + isNull, + ); + }); + }); } diff --git a/test/pages/send_view/frost_ms/recipient_test.dart b/test/pages/send_view/frost_ms/recipient_test.dart new file mode 100644 index 0000000000..72e17ea032 --- /dev/null +++ b/test/pages/send_view/frost_ms/recipient_test.dart @@ -0,0 +1,309 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:platform/platform.dart' as platform; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/pages/send_view/frost_ms/recipient.dart'; +import 'package:stackwallet/providers/global/barcode_scanner_provider.dart'; +import 'package:stackwallet/providers/global/clipboard_provider.dart'; +import 'package:stackwallet/providers/global/locale_provider.dart'; +import 'package:stackwallet/services/locale_service.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/address_utils.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/utilities/barcode_scanner_interface.dart'; +import 'package:stackwallet/utilities/clipboard_interface.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/widgets/desktop/qr_code_scanner_dialog.dart'; + +import '../../../sample_data/theme_json.dart'; + +/// Monero that validates addresses without the native library. +class _TestMonero extends Monero { + _TestMonero() : super(CryptoCurrencyNetwork.main); + + @override + bool validateAddress(String address) => address.startsWith("address"); +} + +/// The mobile camera scanner, returning [rawContent]. +class _FakeScanner implements BarcodeScannerInterface { + _FakeScanner(this.rawContent); + + final String rawContent; + int scans = 0; + + @override + Future scan({required BuildContext context}) async { + scans++; + return ScanResult(rawContent: rawContent); + } +} + +void main() { + final coin = _TestMonero(); + const RecipientId id = (walletId: "wallet", index: 1); + + Amount xmr(String value) => Amount.tryParseCanonicalAmount( + value, + fractionDigits: coin.fractionDigits, + )!; + + late StateProvider unit; + late FakeClipboard clipboard; + late ProviderContainer container; + late List multiRecipientUris; + late _FakeScanner scanner; + + /// Lays widgets out for desktop or mobile on any host platform. + void useLayout({required bool desktop}) { + Util.layoutPlatform = platform.FakePlatform( + operatingSystem: desktop ? "linux" : "android", + ); + Util.screenWidth = null; + Util.isIpad = false; + } + + setUp(() { + final previousPlatform = Util.layoutPlatform; + final previousWidth = Util.screenWidth; + final previousIsIpad = Util.isIpad; + addTearDown(() { + Util.layoutPlatform = previousPlatform; + Util.screenWidth = previousWidth; + Util.isIpad = previousIsIpad; + }); + useLayout(desktop: false); + + scanner = _FakeScanner("monero:address7?tx_amount=0.5&recipient_name=Eve"); + unit = StateProvider((_) => AmountUnit.normal); + clipboard = FakeClipboard(); + multiRecipientUris = []; + container = ProviderContainer( + overrides: [ + pAmountUnit.overrideWithProvider( + (_) => Provider((ref) => ref.watch(unit)), + ), + pAmountFormatter.overrideWithProvider( + (coin) => Provider( + (ref) => AmountFormatter( + unit: ref.watch(pAmountUnit(coin)), + locale: "en_US", + coin: coin, + maxDecimals: coin.fractionDigits, + ), + ), + ), + localeServiceChangeNotifierProvider.overrideWithProvider( + ChangeNotifierProvider((_) => LocaleService()), + ), + pClipboard.overrideWithValue(clipboard), + pBarcodeScanner.overrideWithValue(scanner), + ], + ); + }); + + tearDown(() => container.dispose()); + + RecipientData? recipient() => container.read(pRecipient(id)); + + Future pumpRecipient( + WidgetTester tester, { + RecipientData? initial, + bool single = false, + bool handlesMultiRecipientUris = true, + }) async { + container.read(pRecipient(id).notifier).state = initial; + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + theme: ThemeData( + extensions: [ + StackColors.fromStackColorTheme( + StackTheme.fromJson(json: lightThemeJsonMap), + ), + ], + ), + home: Scaffold( + body: SingleChildScrollView( + child: Recipient( + walletId: id.walletId, + index: id.index, + displayNumber: 1, + coin: coin, + remove: single ? null : () {}, + addAnotherRecipientTapped: () {}, + sendAllTapped: () => "", + onMultiRecipientUri: handlesMultiRecipientUris + ? multiRecipientUris.add + : null, + ), + ), + ), + ), + ), + ); + } + + Finder addressField() => find.byKey(const Key("sendViewAddressFieldKey")); + Finder amountField() => + find.byKey(const Key("amountInputFieldCryptoTextFieldKey")); + String text(WidgetTester tester, Finder finder) => + tester.widget(finder).controller!.text; + + testWidgets("shows the request's name until the address is changed", ( + tester, + ) async { + await pumpRecipient( + tester, + initial: (address: "address0", amount: xmr("1"), label: "Alice"), + ); + + expect(find.text("Alice"), findsOneWidget); + expect(find.text("Invalid address"), findsNothing); + + await tester.enterText(addressField(), "bad"); + await tester.pump(); + + expect(find.text("Alice"), findsNothing); + expect(find.text("Recipient 1"), findsOneWidget); + expect(find.text("Invalid address"), findsOneWidget); + expect(recipient(), (address: "bad", amount: xmr("1"), label: null)); + }); + + testWidgets("keeps the amount when the display unit changes", (tester) async { + container.read(unit.notifier).state = AmountUnit.milli; + await pumpRecipient( + tester, + initial: (address: "address0", amount: xmr("1"), label: null), + ); + expect(text(tester, amountField()), "1000"); + + container.read(unit.notifier).state = AmountUnit.normal; + await tester.pump(); + expect(text(tester, amountField()), "1"); + + // Editing the recipient afterwards must not read "1000" as XMR. + await tester.enterText(addressField(), "address1"); + await tester.pump(); + expect(recipient()!.amount, xmr("1")); + }); + + testWidgets("fills the address, amount, and name from a pasted request", ( + tester, + ) async { + await pumpRecipient(tester); + await clipboard.setData( + const ClipboardData( + text: "monero:address5?tx_amount=0.25&recipient_name=Bob", + ), + ); + + await tester.tap( + find.byKey(const Key("sendViewPasteAddressFieldButtonKey")), + ); + await tester.pumpAndSettle(); + + expect(text(tester, addressField()), "address5"); + expect(text(tester, amountField()), "0.25"); + expect(find.text("Bob"), findsOneWidget); + expect(recipient(), ( + address: "address5", + amount: xmr("0.25"), + label: "Bob", + )); + }); + + testWidgets("fills in a request typed or pasted into the address field", ( + tester, + ) async { + await pumpRecipient(tester); + + await tester.enterText(addressField(), "monero:address5?tx_amount=2"); + await tester.pump(); + + expect(text(tester, addressField()), "address5"); + expect(text(tester, amountField()), "2"); + expect(recipient()!.amount, xmr("2")); + }); + + const multiRecipientUri = + "monero:address1;address2?tx_amount=1;2&recipient_name=A;B"; + + testWidgets("hands a request for several recipients to the screen", ( + tester, + ) async { + await pumpRecipient( + tester, + initial: (address: "address0", amount: xmr("1"), label: null), + ); + + await tester.enterText(addressField(), multiRecipientUri); + await tester.pump(); + + expect(multiRecipientUris.single.recipients.length, 2); + // The recipient is left as it was until the screen replaces it. + expect(text(tester, addressField()), "address0"); + expect(recipient(), (address: "address0", amount: xmr("1"), label: null)); + }); + + testWidgets("rejects requests for several recipients without a handler", ( + tester, + ) async { + await pumpRecipient(tester, single: true, handlesMultiRecipientUris: false); + + await tester.enterText(addressField(), multiRecipientUri); + await tester.pump(); + + expect(find.text("Send to"), findsOneWidget); + expect(find.text("Invalid address"), findsOneWidget); + expect(recipient()!.address, multiRecipientUri); + }); + + testWidgets("clearing the address drops the name", (tester) async { + await pumpRecipient( + tester, + initial: (address: "address0", amount: xmr("1"), label: "Alice"), + ); + + await tester.tap( + find.byKey(const Key("sendViewClearAddressFieldButtonKey")), + ); + await tester.pump(); + + expect(text(tester, addressField()), ""); + expect(find.text("Recipient 1"), findsOneWidget); + expect(recipient(), (address: "", amount: xmr("1"), label: null)); + }); + + testWidgets("scans with the mobile scanner on mobile", (tester) async { + await pumpRecipient(tester); + + await tester.tap(find.byKey(const Key("sendViewScanQrButtonKey"))); + await tester.pumpAndSettle(); + + expect(scanner.scans, 1); + expect(find.byType(QrCodeScannerDialog), findsNothing); + expect(recipient(), ( + address: "address7", + amount: xmr("0.5"), + label: "Eve", + )); + }); + + test("keeps each wallet's recipients separate", () { + const RecipientId other = (walletId: "other", index: 1); + container.read(pRecipient(id).notifier).state = ( + address: "address0", + amount: null, + label: null, + ); + + expect(container.read(pRecipient(other)), isNull); + }); +} diff --git a/test/pages/send_view/sub_widgets/multi_recipient_test.dart b/test/pages/send_view/sub_widgets/multi_recipient_test.dart new file mode 100644 index 0000000000..dd7ea2d26c --- /dev/null +++ b/test/pages/send_view/sub_widgets/multi_recipient_test.dart @@ -0,0 +1,269 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:platform/platform.dart' as platform; +import 'package:stackwallet/models/isar/models/blockchain_data/address.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/pages/send_view/frost_ms/recipient.dart'; +import 'package:stackwallet/pages/send_view/sub_widgets/multi_recipient.dart'; +import 'package:stackwallet/providers/global/locale_provider.dart'; +import 'package:stackwallet/services/locale_service.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/address_utils.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/utilities/util.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; + +import '../../../sample_data/theme_json.dart'; + +/// Monero that validates addresses without the native library. +class _TestMonero extends Monero { + _TestMonero() : super(CryptoCurrencyNetwork.main); + + @override + bool validateAddress(String address) => address.startsWith("address"); +} + +void main() { + final coin = _TestMonero(); + + PaymentUriData uri(List amounts, {List? addresses}) => + PaymentUriData( + scheme: "monero", + recipients: [ + for (int i = 0; i < amounts.length; i++) + PaymentUriRecipient( + address: addresses?[i] ?? "address$i", + amount: amounts[i], + label: i == 0 ? "Alice" : null, + ), + ], + additionalParams: const {}, + ); + + Amount xmr(String value) => Amount.tryParseCanonicalAmount( + value, + fractionDigits: coin.fractionDigits, + )!; + + test("parses each recipient's amount in the coin's units", () { + final recipients = parseUriRecipients(uri(["1.5", "0.000000000001"]), coin); + + expect(recipients, [ + (address: "address0", amount: xmr("1.5"), label: "Alice"), + (address: "address1", amount: xmr("0.000000000001"), label: null), + ]); + expect(recipients!.total, xmr("1.500000000001")); + }); + + test("truncates amounts more precise than the coin", () { + final recipients = parseUriRecipients(uri(["0.1234567890129", "1"]), coin); + + expect(recipients!.first.amount, xmr("0.123456789012")); + }); + + test("rejects missing and zero amounts", () { + expect(parseUriRecipients(uri(["1", null]), coin), isNull); + expect(parseUriRecipients(uri(["1", "0"]), coin), isNull); + expect(parseUriRecipients(uri(["1", "0.0000000000001"]), coin), isNull); + }); + + test("builds one transaction output per recipient", () { + final recipients = parseUriRecipients(uri(["1", "2"]), coin)!; + + expect(recipients.isValidFor(coin), isTrue); + + final outputs = recipients.toTxRecipients(coin); + expect(outputs.map((e) => e.address), ["address0", "address1"]); + expect(outputs.map((e) => e.amount), [xmr("1"), xmr("2")]); + expect(outputs.map((e) => e.isChange), [false, false]); + expect(outputs.map((e) => e.addressType), [ + AddressType.cryptonote, + AddressType.cryptonote, + ]); + }); + + test("is invalid unless every recipient has an address and amount", () { + final RecipientData valid = ( + address: "address0", + amount: xmr("1"), + label: null, + ); + + expect([valid, valid].isValidFor(coin), isTrue); + for (final RecipientData invalid in [ + (address: "bad", amount: xmr("2"), label: null), + (address: "address1", amount: null, label: null), + (address: "address1", amount: xmr("0"), label: null), + ]) { + expect([valid, invalid].isValidFor(coin), isFalse); + } + }); + + test("has no total while an amount is missing", () { + expect( + [ + (address: "address0", amount: xmr("1"), label: null), + (address: "address1", amount: null, label: null), + ].total, + isNull, + ); + }); + + late ProviderContainer container; + + /// Lays widgets out for desktop or mobile on any host platform. + void useLayout({required bool desktop}) { + Util.layoutPlatform = platform.FakePlatform( + operatingSystem: desktop ? "linux" : "android", + ); + Util.screenWidth = null; + Util.isIpad = false; + } + + setUp(() { + final previousPlatform = Util.layoutPlatform; + final previousWidth = Util.screenWidth; + final previousIsIpad = Util.isIpad; + addTearDown(() { + Util.layoutPlatform = previousPlatform; + Util.screenWidth = previousWidth; + Util.isIpad = previousIsIpad; + }); + + container = ProviderContainer( + overrides: [ + pAmountUnit.overrideWithProvider( + (_) => Provider((_) => AmountUnit.normal), + ), + pAmountFormatter.overrideWithProvider( + (coin) => Provider( + (_) => AmountFormatter( + unit: AmountUnit.normal, + locale: "en_US", + coin: coin, + maxDecimals: coin.fractionDigits, + ), + ), + ), + localeServiceChangeNotifierProvider.overrideWithProvider( + ChangeNotifierProvider((_) => LocaleService()), + ), + ], + ); + }); + + tearDown(() => container.dispose()); + + Future pump(WidgetTester tester, Widget child) => tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + theme: ThemeData( + extensions: [ + StackColors.fromStackColorTheme( + StackTheme.fromJson(json: lightThemeJsonMap), + ), + ], + ), + home: Scaffold(body: SingleChildScrollView(child: child)), + ), + ), + ); + + testWidgets("confirmation list shows names and amounts", (tester) async { + await pump( + tester, + RecipientAmountList( + coin: coin, + recipients: parseUriRecipients( + uri(["1", "2.50", "0.000000000001"]), + coin, + )!.toTxRecipients(coin), + labels: const ["Alice", null, null], + labelStyle: const TextStyle(), + valueStyle: const TextStyle(), + ), + ); + + expect(find.text("Alice"), findsOneWidget); + expect(find.text("Recipient 2"), findsOneWidget); + expect(find.text("Recipient 3"), findsOneWidget); + expect(find.text("1 XMR"), findsOneWidget); + expect(find.text("2.5 XMR"), findsOneWidget); + expect(find.text("0.000000000001 XMR"), findsOneWidget); + }); + + for (final isDesktop in [true, false]) { + final platform = isDesktop ? "desktop" : "mobile"; + + testWidgets("lists a form per recipient on $platform", (tester) async { + useLayout(desktop: isDesktop); + for (final (index, address) in [(3, "address0"), (7, "bad")]) { + container + .read(pRecipient((walletId: "wallet", index: index)).notifier) + .state = ( + address: address, + amount: xmr("1"), + label: null, + ); + } + int added = 0; + final List removed = []; + + await pump( + tester, + RecipientForms( + walletId: "wallet", + coin: coin, + indexes: const [3, 7], + onChanged: () {}, + onAdd: () => added++, + onRemove: removed.add, + onMultiRecipientUri: (_) {}, + ), + ); + + expect(find.text("Recipient 1"), findsOneWidget); + expect(find.text("Recipient 2"), findsOneWidget); + expect(find.widgetWithText(TextField, "address0"), findsOneWidget); + expect(find.widgetWithText(TextField, "bad"), findsOneWidget); + expect(find.text("Invalid address"), findsOneWidget); + + await tester.tap(find.text("Add recipient")); + await tester.tap(find.text("Remove", findRichText: true).last); + await tester.pump(); + + expect(added, 1); + expect(removed, [7]); + }); + } + + for (final (button, replace) in [("Replace", true), ("Cancel", false)]) { + testWidgets("asks before replacing recipients ($button)", (tester) async { + useLayout(desktop: false); + bool? result; + await pump( + tester, + Builder( + builder: (context) => TextButton( + onPressed: () async => + result = await confirmReplaceRecipients(context, count: 3), + child: const Text("open"), + ), + ), + ); + + await tester.tap(find.text("open")); + await tester.pumpAndSettle(); + expect(find.text("Replace recipients?"), findsOneWidget); + expect(find.textContaining("has 3 recipients"), findsOneWidget); + + await tester.tap(find.text(button)); + await tester.pumpAndSettle(); + expect(result, replace); + }); + } +}