diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index ed4e967792..a64aeada68 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -195,9 +195,8 @@ class _ConfirmTransactionViewState if (context.mounted) { widget.onSuccess.call(); if (widget.onSuccessInsteadOfRouteOnSuccess == null) { - Navigator.of( - context, - ).popUntil(ModalRoute.withName(routeOnSuccessName)); + Navigator.of(context) + .popUntil(ModalRoute.withName(routeOnSuccessName)); } else { widget.onSuccessInsteadOfRouteOnSuccess!.call(); } @@ -274,9 +273,8 @@ class _ConfirmTransactionViewState if (context.mounted) { widget.onSuccess.call(); if (widget.onSuccessInsteadOfRouteOnSuccess == null) { - Navigator.of( - context, - ).popUntil(ModalRoute.withName(routeOnSuccessName)); + Navigator.of(context) + .popUntil(ModalRoute.withName(routeOnSuccessName)); } else { widget.onSuccessInsteadOfRouteOnSuccess!.call(); } @@ -483,9 +481,8 @@ class _ConfirmTransactionViewState if (context.mounted) { if (widget.onSuccessInsteadOfRouteOnSuccess == null) { - Navigator.of( - context, - ).popUntil(ModalRoute.withName(routeOnSuccessName)); + Navigator.of(context) + .popUntil(ModalRoute.withName(routeOnSuccessName)); } else { widget.onSuccessInsteadOfRouteOnSuccess!.call(); } @@ -563,9 +560,9 @@ class _ConfirmTransactionViewState child: Text( "Ok", style: STextStyles.button(context).copyWith( - color: Theme.of( - context, - ).extension()!.accentColorDark, + color: Theme.of(context) + .extension()! + .accentColorDark, ), ), onPressed: () { @@ -673,13 +670,13 @@ class _ConfirmTransactionViewState condition: !isDesktop, builder: (child) => Background( child: Scaffold( - backgroundColor: Theme.of( - context, - ).extension()!.background, + backgroundColor: Theme.of(context) + .extension()! + .background, appBar: AppBar( - backgroundColor: Theme.of( - context, - ).extension()!.background, + backgroundColor: Theme.of(context) + .extension()! + .background, leading: AppBarBackButton( onPressed: () async { // if (FocusScope.of(context).hasFocus) { @@ -779,6 +776,25 @@ class _ConfirmTransactionViewState ], ), ), + if (widget.txData.openAliasRecipient != null) ...[ + const SizedBox(height: 12), + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "OpenAlias", + style: STextStyles.smallMed12(context), + ), + const SizedBox(height: 4), + Text( + widget.txData.openAliasRecipient!.displayAlias, + style: STextStyles.itemSubtitle12(context), + ), + ], + ), + ), + ], const SizedBox(height: 12), RoundedWhiteContainer( child: Row( @@ -913,18 +929,18 @@ class _ConfirmTransactionViewState ), child: RoundedWhiteContainer( padding: const EdgeInsets.all(0), - borderColor: Theme.of( - context, - ).extension()!.background, + borderColor: Theme.of(context) + .extension()! + .background, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container( decoration: BoxDecoration( - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, borderRadius: BorderRadius.only( topLeft: Radius.circular( Constants.size.circularBorderRadius, @@ -1091,9 +1107,9 @@ class _ConfirmTransactionViewState ), Container( height: 1, - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, ), Padding( padding: const EdgeInsets.all(12), @@ -1128,20 +1144,55 @@ class _ConfirmTransactionViewState STextStyles.desktopTextExtraExtraSmall( context, ).copyWith( - color: Theme.of( - context, - ).extension()!.textDark, + color: Theme.of(context) + .extension()! + .textDark, ), ), ], ), ), + if (widget.txData.openAliasRecipient != null) ...[ + Container( + height: 1, + color: Theme.of(context) + .extension()! + .background, + ), + Padding( + padding: const EdgeInsets.all(12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "OpenAlias", + style: STextStyles.desktopTextExtraExtraSmall( + context, + ), + ), + const SizedBox(height: 2), + SelectableText( + widget.txData.openAliasRecipient!.displayAlias, + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of(context) + .extension()! + .textDark, + ), + ), + ], + ), + ), + ], if (widget.isPaynymTransaction) Container( height: 1, - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, ), if (widget.isPaynymTransaction) Padding( @@ -1163,9 +1214,9 @@ class _ConfirmTransactionViewState STextStyles.desktopTextExtraExtraSmall( context, ).copyWith( - color: Theme.of( - context, - ).extension()!.textDark, + color: Theme.of(context) + .extension()! + .textDark, ), ), ], @@ -1174,9 +1225,9 @@ class _ConfirmTransactionViewState if (coin is Ethereum) Container( height: 1, - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, ), if (coin is Ethereum) Padding( @@ -1198,9 +1249,9 @@ class _ConfirmTransactionViewState STextStyles.desktopTextExtraExtraSmall( context, ).copyWith( - color: Theme.of( - context, - ).extension()!.textDark, + color: Theme.of(context) + .extension()! + .textDark, ), ), ], @@ -1331,9 +1382,9 @@ class _ConfirmTransactionViewState focusNode: _noteFocusNode, style: STextStyles.desktopTextExtraSmall(context) .copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, + color: Theme.of(context) + .extension()! + .textFieldActiveText, height: 1.8, ), onChanged: (_) => setState(() {}), @@ -1393,9 +1444,9 @@ class _ConfirmTransactionViewState horizontal: 16, vertical: 18, ), - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of(context) + .extension()! + .textFieldDefaultBG, child: SelectableText( ref.watch(pAmountFormatter(coin)).format(fee!), style: STextStyles.itemSubtitle(context), @@ -1424,9 +1475,9 @@ class _ConfirmTransactionViewState horizontal: 16, vertical: 18, ), - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of(context) + .extension()! + .textFieldDefaultBG, child: SelectableText( "~${fee!.raw.toInt() ~/ widget.txData.vSize!}", style: STextStyles.itemSubtitle(context), @@ -1444,22 +1495,21 @@ class _ConfirmTransactionViewState padding: isDesktop ? const EdgeInsets.symmetric(horizontal: 16, vertical: 18) : const EdgeInsets.all(12), - color: Theme.of( - context, - ).extension()!.snackBarBackSuccess, + color: Theme.of(context) + .extension()! + .snackBarBackSuccess, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( isDesktop ? "Total amount to send" : "Total amount", style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context) - .extension()! - .textConfirmTotalAmount, - ) + ? STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ) : STextStyles.titleBold12(context).copyWith( color: Theme.of(context) .extension()! @@ -1471,13 +1521,12 @@ class _ConfirmTransactionViewState .watch(pAmountFormatter(coin)) .format(amountWithoutChange + fee!), style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context) - .extension()! - .textConfirmTotalAmount, - ) + ? STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ) : STextStyles.itemSubtitle12(context).copyWith( color: Theme.of(context) .extension()! @@ -1495,16 +1544,16 @@ class _ConfirmTransactionViewState ? const EdgeInsets.symmetric(horizontal: 32, vertical: 8) : const EdgeInsets.symmetric(vertical: 8), child: RoundedContainer( - color: Theme.of( - context, - ).extension()!.warningBackground, + color: Theme.of(context) + .extension()! + .warningBackground, child: Row( children: [ Icon( Icons.warning_amber_rounded, - color: Theme.of( - context, - ).extension()!.warningForeground, + color: Theme.of(context) + .extension()! + .warningForeground, size: 20, ), const SizedBox(width: 8), @@ -1513,9 +1562,9 @@ class _ConfirmTransactionViewState "This transaction spends a UTXO containing " "an ordinal inscription.", style: STextStyles.smallMed12(context).copyWith( - color: Theme.of( - context, - ).extension()!.warningForeground, + color: Theme.of(context) + .extension()! + .warningForeground, ), ), ), diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 13615a092d..338b27340d 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -29,6 +29,8 @@ import '../../providers/ui/fee_rate_type_state_provider.dart'; import '../../providers/ui/preview_tx_button_state_provider.dart'; import '../../providers/wallet/public_private_balance_state_provider.dart'; import '../../route_generator.dart'; +import '../../services/openalias/send_preview.dart'; +import '../../services/openalias/send_recipient.dart'; import '../../services/spark_names_service.dart'; import '../../themes/coin_icon_provider.dart'; import '../../themes/stack_colors.dart'; @@ -77,6 +79,7 @@ import '../../widgets/icon_widgets/clipboard_icon.dart'; import '../../widgets/icon_widgets/qrcode_icon.dart'; import '../../widgets/icon_widgets/x_icon.dart'; import '../../widgets/mwc_txs_method_toggle.dart'; +import '../../widgets/transaction_preview_dialog.dart'; import '../../widgets/rounded_white_container.dart'; import '../../widgets/stack_dialog.dart'; import '../../widgets/stack_text_field.dart'; @@ -124,6 +127,39 @@ class _SendViewState extends ConsumerState { late final CryptoCurrency coin; late final ClipboardInterface clipboard; + final _preview = SendPreview(); + List _previewInput = []; + + bool get _isOpenAliasInput => + coin is Monero && + SendRecipient.classify( + _address ?? '', + supportsOpenAlias: true, + validateAddress: coin.validateAddress, + ).isAlias; + + void _previewChanged() { + if (mounted) setState(() {}); + } + + void _previewInputChanged() { + final input = [ + sendToController, + cryptoAmountController, + baseAmountController, + memoController, + noteController, + onChainNoteController, + feeController, + ].map((c) => c.text).toList(); + if (input.length != _previewInput.length || + Iterable.generate(input.length) + .any((i) => input[i] != _previewInput[i])) { + _previewInput = input; + _preview.invalidate(); + } + } + late TextEditingController sendToController; late TextEditingController cryptoAmountController; late TextEditingController baseAmountController; @@ -146,7 +182,12 @@ class _SendViewState extends ConsumerState { late final bool isEth; Amount? _cachedAmountToSend; - String? _address; + String? _addressValue; + String? get _address => _addressValue; + set _address(String? value) { + if (_addressValue != value) _preview.invalidate(); + _addressValue = value; + } bool _addressToggleFlag = false; @@ -835,9 +876,41 @@ class _SendViewState extends ConsumerState { } Future _previewTransaction() async { + final source = _address ?? ''; + final draftFee = customFeeRate; + final draftEthFee = _ethFee.value; + final draftBalance = ref.read(publicPrivateBalanceStateProvider); + + final wallet = ref.read(pWallets).getWallet(walletId); + FocusScope.of(context).unfocus(); + await _preview.run( + walletId: walletId, + source: source, + isCurrent: () => + mounted && + widget.walletId == walletId && + (_address ?? '') == source && + customFeeRate == draftFee && + _ethFee.value == draftEthFee && + ref.read(publicPrivateBalanceStateProvider) == draftBalance && + identical(ref.read(pWallets).getWallet(walletId), wallet), + work: _preparePreview, + onError: (error, stack) { + Logging.instance.e( + 'Send preview failed', + error: error, + stackTrace: stack, + ); + showTransactionFailedDialog(context, error, isDesktop: false); + }, + ); + } + + Future _preparePreview(SendPreviewAttempt attempt) async { // wait for keyboard to disappear FocusScope.of(context).unfocus(); await Future.delayed(const Duration(milliseconds: 100)); + attempt.checkCurrent(); final wallet = ref.read(pWallets).getWallet(walletId); final Amount amount = ref.read(pSendAmount)!; @@ -910,6 +983,7 @@ class _SendViewState extends ConsumerState { ); } + attempt.checkCurrent(); if (shouldSendAll == null || shouldSendAll == false) { // cancel preview return; @@ -917,38 +991,42 @@ class _SendViewState extends ConsumerState { } } + attempt.checkCurrent(); + if (!mounted) return; + final progress = TransactionPreviewDialog(); + attempt.onClose(progress.close); try { - bool wasCancelled = false; - - if (mounted) { - unawaited( - showDialog( - context: context, - useSafeArea: false, - barrierDismissible: false, - builder: (context) { - return BuildingTransactionDialog( - coin: wallet.info.coin, - isSpark: - wallet is FiroWallet && - ref.read(publicPrivateBalanceStateProvider.state).state == - BalanceType.private, - onCancel: () { - wasCancelled = true; - - Navigator.of(context).pop(); - }, - ); - }, - ), - ); - } + progress.show( + context, + (context) => BuildingTransactionDialog( + coin: wallet.info.coin, + isSpark: + wallet is FiroWallet && + ref.read(publicPrivateBalanceStateProvider) == + BalanceType.private, + closeOnCancel: false, + onCancel: attempt.cancel, + ), + attempt.cancel, + ); final time = Future.delayed(const Duration(milliseconds: 2500)); Future txDataFuture; final feeRateType = ref.read(feeRateTypeMobileStateProvider); final satsPerVByte = feeRateType.customSatsPerVByte(customFeeRate); + final resolved = await attempt.resolve( + supportsOpenAlias: coin is Monero, + validateAddress: wallet.cryptoCurrency.validateAddress, + lookup: (input) => ref + .read(pSendOpenAliasService) + .resolve( + input, + validateAddress: wallet.cryptoCurrency.validateAddress, + ), + ); + attempt.checkCurrent(); + final destination = resolved.destination; if (isPaynymSend) { txDataFuture = (wallet as PaynymInterface).preparePaymentCodeSend( @@ -982,7 +1060,7 @@ class _SendViewState extends ConsumerState { txData: TxData( sparkRecipients: [ ( - address: _address!, + address: destination, amount: amount, memo: memoController.text, isChange: false, @@ -1000,11 +1078,11 @@ class _SendViewState extends ConsumerState { txData: TxData( recipients: [ TxRecipient( - address: _address!, + address: destination, amount: amount, isChange: false, addressType: wallet.cryptoCurrency.getAddressType( - _address!, + destination, )!, ), ], @@ -1026,18 +1104,18 @@ class _SendViewState extends ConsumerState { ? null : [ TxRecipient( - address: _address!, + address: destination, amount: amount, isChange: false, addressType: wallet.cryptoCurrency.getAddressType( - _address!, + destination, )!, ), ], sparkRecipients: ref.read(pValidSparkSendToAddress) ? [ ( - address: _address!, + address: destination, amount: amount, memo: memoController.text, isChange: false, @@ -1055,10 +1133,10 @@ class _SendViewState extends ConsumerState { txData: TxData( recipients: [ TxRecipient( - address: _address!, + address: destination, amount: amount, isChange: false, - addressType: wallet.cryptoCurrency.getAddressType(_address!)!, + addressType: wallet.cryptoCurrency.getAddressType(destination)!, ), ], feeRateType: feeRateType, @@ -1077,12 +1155,13 @@ class _SendViewState extends ConsumerState { final memo = coin is Stellar ? memoController.text : null; txDataFuture = wallet.prepareSend( txData: TxData( + openAliasRecipient: resolved.alias, recipients: [ TxRecipient( - address: _address!, + address: destination, amount: amount, isChange: false, - addressType: wallet.cryptoCurrency.getAddressType(_address!)!, + addressType: wallet.cryptoCurrency.getAddressType(destination)!, ), ], memo: memo, @@ -1104,7 +1183,8 @@ class _SendViewState extends ConsumerState { TxData txData = results.first as TxData; - if (!wasCancelled && mounted) { + attempt.checkCurrent(); + if (mounted) { if (isPaynymSend) { txData = txData.copyWith( paynymAccountLite: widget.accountLite!, @@ -1117,8 +1197,7 @@ class _SendViewState extends ConsumerState { txData = txData.copyWith(noteOnChain: onChainNoteController.text); } - // pop building dialog - Navigator.of(context, rootNavigator: true).pop(); + progress.close(); unawaited( Navigator.of(context).push( @@ -1141,46 +1220,13 @@ class _SendViewState extends ConsumerState { ), ); } - } catch (e, s) { - Logging.instance.e("$e\n$s", error: e, stackTrace: s); - if (mounted) { - // pop building dialog - Navigator.of(context, rootNavigator: true).pop(); - - unawaited( - showDialog( - context: context, - useSafeArea: false, - barrierDismissible: true, - builder: (context) { - return StackDialog( - title: "Transaction failed", - message: e.toString(), - rightButton: TextButton( - style: Theme.of(context) - .extension()! - .getSecondaryEnabledButtonStyle(context), - child: Text( - "Ok", - style: STextStyles.button(context).copyWith( - color: Theme.of(context) - .extension()! - .accentColorDark, - ), - ), - onPressed: () { - Navigator.of(context).pop(); - }, - ), - ); - }, - ), - ); - } + } finally { + progress.close(); } } void clearSendForm() { + _preview.invalidate(); if (!mounted) { return; } @@ -1312,6 +1358,20 @@ class _SendViewState extends ConsumerState { feeController = TextEditingController(); memoController = TextEditingController(); + _preview.addListener(_previewChanged); + for (final controller in [ + sendToController, + cryptoAmountController, + baseAmountController, + memoController, + noteController, + onChainNoteController, + feeController, + ]) { + controller.addListener(_previewInputChanged); + } + _previewInputChanged(); + onCryptoAmountChanged = _cryptoAmountChanged; cryptoAmountController.addListener(onCryptoAmountChanged); baseAmountController.addListener(_baseAmountChanged); @@ -1384,8 +1444,16 @@ class _SendViewState extends ConsumerState { super.initState(); } + @override + void didUpdateWidget(covariant SendView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.walletId != widget.walletId) _preview.invalidate(); + } + @override void dispose() { + _preview.removeListener(_previewChanged); + _preview.dispose(); _cryptoAmountChangedFeeUpdateTimer?.cancel(); _baseAmountChangedFeeUpdateTimer?.cancel(); _ethFee.dispose(); @@ -1413,6 +1481,16 @@ class _SendViewState extends ConsumerState { @override Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType"); + ref.listen(pSendAmount, (previous, next) { + if (previous != next) _preview.invalidate(); + }); + ref.listen(pOpReturnData, (previous, next) { + if (previous != next) _preview.invalidate(); + }); + ref.listen(feeRateTypeMobileStateProvider, (previous, next) { + if (previous != next) _preview.invalidate(); + }); + final isCustomFee = ref.watch(feeRateTypeMobileStateProvider).isCustom; final String locale = ref.watch( localeServiceChangeNotifierProvider.select((value) => value.locale), @@ -1428,7 +1506,14 @@ class _SendViewState extends ConsumerState { // ethFee is checked in the ValueListenableBuilder around the preview // button so fee keystrokes don't rebuild this whole view. final previewEnabled = - ref.watch(pPreviewTxButtonEnabled(coin)) && + !_preview.busy && + widget.walletId == walletId && + ref.watch( + pPreviewTxButtonEnabledForDestination(( + coin: coin, + isAlias: _isOpenAliasInput, + )), + ) && (ref.watch(pOpReturnData) == null || balType != BalanceType.private); final needsEthFee = isEth && isCustomFee; @@ -1559,106 +1644,118 @@ class _SendViewState extends ConsumerState { height: 22, ), const SizedBox(width: 6), - Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - ref.watch(pWalletName(walletId)), - style: STextStyles.titleBold12( - context, - ).copyWith(fontSize: 14), - overflow: TextOverflow.ellipsis, - maxLines: 1, - ), - // const SizedBox( - // height: 2, - // ), - if (isFiro || isMwebEnabled) - Text( - "${balType.name.capitalize()} balance", - style: STextStyles.label(context) - .copyWith(fontSize: 10), - ), - if (coin is! Firo) + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ Text( - "Available balance", - style: STextStyles.label(context) - .copyWith(fontSize: 10), + ref.watch(pWalletName(walletId)), + style: STextStyles.titleBold12( + context, + ).copyWith(fontSize: 14), + overflow: TextOverflow.ellipsis, + maxLines: 1, ), - ], + // const SizedBox( + // height: 2, + // ), + if (isFiro || isMwebEnabled) + Text( + "${balType.name.capitalize()} balance", + style: STextStyles.label(context) + .copyWith(fontSize: 10), + ), + if (coin is! Firo) + Text( + "Available balance", + style: STextStyles.label(context) + .copyWith(fontSize: 10), + ), + ], + ), ), - const Spacer(), - Builder( - builder: (context) { - final Amount amount; - if (showPrivateBalance) { - switch (balType) { - case BalanceType.public: - amount = ref - .read( - pWalletBalance(walletId), - ) - .spendable; - break; - - case BalanceType.private: - amount = ref - .read( - isMwebEnabled - ? pWalletBalanceSecondary( - walletId, - ) - : pWalletBalanceTertiary( - walletId, - ), - ) - .spendable; - break; + const SizedBox(width: 12), + Expanded( + child: Builder( + builder: (context) { + final Amount amount; + if (showPrivateBalance) { + switch (balType) { + case BalanceType.public: + amount = ref + .read( + pWalletBalance(walletId), + ) + .spendable; + break; + + case BalanceType.private: + amount = ref + .read( + isMwebEnabled + ? pWalletBalanceSecondary( + walletId, + ) + : pWalletBalanceTertiary( + walletId, + ), + ) + .spendable; + break; + } + } else { + amount = ref + .read(pWalletBalance(walletId)) + .spendable; } - } else { - amount = ref - .read(pWalletBalance(walletId)) - .spendable; - } - return GestureDetector( - onTap: () { - cryptoAmountController.text = ref - .read(pAmountFormatter(coin)) - .formatEditable(amount); - }, - child: Container( - color: Colors.transparent, - child: Column( - crossAxisAlignment: - CrossAxisAlignment.end, - children: [ - Text( - ref - .watch( - pAmountFormatter(coin), - ) - .format(amount), - style: - STextStyles.titleBold12( - context, - ).copyWith(fontSize: 10), - textAlign: TextAlign.right, - ), - if (price != null) + return GestureDetector( + onTap: () { + cryptoAmountController.text = ref + .read(pAmountFormatter(coin)) + .formatEditable(amount); + }, + child: Container( + color: Colors.transparent, + child: Column( + crossAxisAlignment: + CrossAxisAlignment.end, + children: [ Text( - "${(amount.decimal * price).toAmount(fractionDigits: 2).fiatString(locale: locale)} ${ref.watch(prefsChangeNotifierProvider.select((value) => value.currency))}", - style: STextStyles.subtitle( - context, - ).copyWith(fontSize: 8), + ref + .watch( + pAmountFormatter( + coin, + ), + ) + .format(amount), + style: + STextStyles.titleBold12( + context, + ).copyWith( + fontSize: 10, + ), textAlign: TextAlign.right, ), - ], + if (price != null) + Text( + "${(amount.decimal * price).toAmount(fractionDigits: 2).fiatString(locale: locale)} ${ref.watch(prefsChangeNotifierProvider.select((value) => value.currency))}", + style: + STextStyles.subtitle( + context, + ).copyWith( + fontSize: 8, + ), + textAlign: + TextAlign.right, + ), + ], + ), ), - ), - ); - }, + ); + }, + ), ), ], ), @@ -1685,33 +1782,14 @@ class _SendViewState extends ConsumerState { ], if (!isSlatepackMode) - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Text( - isPaynymSend - ? "Send to PayNym address" - : "Send to", - style: STextStyles.smallMed12(context), - textAlign: TextAlign.left, - ), - // if (coin is Monero) - // CustomTextButton( - // text: "Use OpenAlias", - // onTap: () async { - // await showModalBottomSheet( - // context: context, - // builder: (context) => - // OpenAliasBottomSheet( - // onSelected: (address) { - // sendToController.text = address; - // }, - // ), - // ); - // }, - // ), - ], + Text( + isPaynymSend + ? "Send to PayNym address" + : coin is Monero + ? "Send to address or OpenAlias" + : "Send to", + style: STextStyles.smallMed12(context), + textAlign: TextAlign.left, ), if (!isSlatepackMode) const SizedBox(height: 8), if (isPaynymSend) @@ -2022,7 +2100,8 @@ class _SendViewState extends ConsumerState { if (_data != null && _data.contactLabel == _address) { error = null; - } else if (!ref.watch(pValidSendToAddress)) { + } else if (!_isOpenAliasInput && + !ref.watch(pValidSendToAddress)) { error = "Invalid address"; } else { error = null; diff --git a/lib/pages/send_view/sub_widgets/building_transaction_dialog.dart b/lib/pages/send_view/sub_widgets/building_transaction_dialog.dart index 0d1e9ef344..5fd25458f5 100644 --- a/lib/pages/send_view/sub_widgets/building_transaction_dialog.dart +++ b/lib/pages/send_view/sub_widgets/building_transaction_dialog.dart @@ -12,6 +12,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; + import '../../../themes/coin_image_provider.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/text_styles.dart'; @@ -27,11 +28,13 @@ class BuildingTransactionDialog extends ConsumerStatefulWidget { required this.onCancel, required this.coin, required this.isSpark, + this.closeOnCancel = true, }); final VoidCallback onCancel; final CryptoCurrency coin; final bool isSpark; + final bool closeOnCancel; @override ConsumerState createState() => @@ -115,7 +118,9 @@ class _RestoringDialogState extends ConsumerState { style: STextStyles.itemSubtitle12(context), ), onPressed: () { - Navigator.of(context).pop(); + if (widget.closeOnCancel) { + Navigator.of(context).pop(); + } onCancel.call(); }, ), @@ -140,7 +145,9 @@ class _RestoringDialogState extends ConsumerState { style: STextStyles.itemSubtitle12(context), ), onPressed: () { - Navigator.of(context).pop(); + if (widget.closeOnCancel) { + Navigator.of(context).pop(); + } onCancel.call(); }, ), diff --git a/lib/pages/send_view/sub_widgets/openalias_sheet.dart b/lib/pages/send_view/sub_widgets/openalias_sheet.dart deleted file mode 100644 index d2bf5d302d..0000000000 --- a/lib/pages/send_view/sub_widgets/openalias_sheet.dart +++ /dev/null @@ -1,151 +0,0 @@ -// /* -// * This file is part of Stack Wallet. -// * -// * Copyright (c) 2023 Cypher Stack -// * All Rights Reserved. -// * The code is distributed under GPLv3 license, see LICENSE file for details. -// * Generated by Cypher Stack on 2023-05-26 -// * -// */ -// -// import 'package:basic_utils/basic_utils.dart'; -// import 'package:flutter/material.dart'; -// import 'package:flutter_riverpod/flutter_riverpod.dart'; -// import 'package:stackwallet/themes/stack_colors.dart'; -// import 'package:stackwallet/utilities/constants.dart'; -// import 'package:stackwallet/utilities/text_styles.dart'; -// -// class OpenAliasBottomSheet extends ConsumerStatefulWidget { -// const OpenAliasBottomSheet({ -// Key? key, -// required this.onSelected, -// }) : super(key: key); -// -// final Null Function(String) onSelected; -// -// @override -// ConsumerState createState() => -// _OpenAliasBottomSheetState(); -// } -// -// class _OpenAliasBottomSheetState extends ConsumerState { -// late TextEditingController textEditingController; -// -// @override -// void initState() { -// super.initState(); -// textEditingController = TextEditingController(); -// } -// -// @override -// Widget build(BuildContext context) { -// return SingleChildScrollView( -// child: Padding( -// padding: -// EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), -// child: Container( -// decoration: BoxDecoration( -// color: Theme.of(context).extension()!.popupBG, -// borderRadius: const BorderRadius.vertical( -// top: Radius.circular(20), -// ), -// ), -// child: Padding( -// padding: const EdgeInsets.only( -// top: 20, -// left: 20, -// right: 20, -// bottom: 20, -// ), -// child: Column( -// mainAxisSize: MainAxisSize.min, -// crossAxisAlignment: CrossAxisAlignment.start, -// children: [ -// Center( -// child: Container( -// decoration: BoxDecoration( -// color: Theme.of(context) -// .extension()! -// .textFieldDefaultBG, -// borderRadius: BorderRadius.circular( -// Constants.size.circularBorderRadius, -// ), -// ), -// width: 60, -// height: 4, -// ), -// ), -// const SizedBox( -// height: 36, -// ), -// Text( -// 'OpenAlias Address', -// style: STextStyles.smallMed12(context), -// textAlign: TextAlign.left, -// ), -// const SizedBox( -// height: 8, -// ), -// TextField( -// controller: textEditingController, -// decoration: const InputDecoration( -// border: OutlineInputBorder(), -// ), -// ), -// const SizedBox( -// height: 16, -// ), -// Align( -// alignment: Alignment.bottomCenter, -// child: ConstrainedBox( -// constraints: const BoxConstraints( -// minWidth: 480, -// minHeight: 70, -// ), -// child: TextButton( -// style: Theme.of(context) -// .extension()! -// .getPrimaryEnabledButtonStyle(context), -// onPressed: () async { -// String text = textEditingController.text -// .trim() -// .replaceAll("@", "."); -// List? result = -// await DnsUtils.lookupRecord(text, RRecordType.TXT); -// String address = ""; -// if (result != null && result.isNotEmpty) { -// for (RRecord record in result) { -// if (record.data.startsWith("oa1:xmr")) { -// List datas = record.data.split(" "); -// for (String data in datas) { -// if (data.startsWith("recipient_address=")) { -// address = data -// .substring("recipient_address=".length) -// .replaceAll(";", ""); -// break; -// } -// } -// break; -// } -// } -// } -// widget.onSelected(address); -// if (mounted) { -// Navigator.of(context).pop(); -// } -// }, -// child: Text( -// "Enter", -// style: STextStyles.button(context), -// ), -// ), -// ), -// ) -// ], -// ), -// ), -// ), -// ), -// ); -// } -// } 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 09d2e67979..21dfc306e9 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 @@ -34,6 +34,8 @@ import '../../../../providers/ui/fee_rate_type_state_provider.dart'; import '../../../../providers/ui/preview_tx_button_state_provider.dart'; import '../../../../providers/wallet/desktop_fee_providers.dart'; import '../../../../providers/wallet/public_private_balance_state_provider.dart'; +import '../../../../services/openalias/send_preview.dart'; +import '../../../../services/openalias/send_recipient.dart'; import '../../../../services/spark_names_service.dart'; import '../../../../themes/stack_colors.dart'; import '../../../../utilities/address_utils.dart'; @@ -78,6 +80,7 @@ import '../../../../widgets/icon_widgets/clipboard_icon.dart'; import '../../../../widgets/icon_widgets/qrcode_icon.dart'; import '../../../../widgets/icon_widgets/x_icon.dart'; import '../../../../widgets/mwc_txs_method_toggle.dart'; +import '../../../../widgets/transaction_preview_dialog.dart'; import '../../../../widgets/rounded_container.dart'; import '../../../../widgets/stack_text_field.dart'; import '../../../../widgets/textfield_icon_button.dart'; @@ -110,6 +113,37 @@ class _DesktopSendState extends ConsumerState { late final CryptoCurrency coin; late final ClipboardInterface clipboard; + final _preview = SendPreview(); + List _previewInput = []; + + bool get _isOpenAliasInput => + coin is Monero && + SendRecipient.classify( + _address ?? '', + supportsOpenAlias: true, + validateAddress: coin.validateAddress, + ).isAlias; + + void _previewChanged() { + if (mounted) setState(() {}); + } + + void _previewInputChanged() { + final input = [ + sendToController, + cryptoAmountController, + baseAmountController, + memoController, + nonceController, + ].map((c) => c.text).toList(); + if (input.length != _previewInput.length || + Iterable.generate(input.length) + .any((i) => input[i] != _previewInput[i])) { + _previewInput = input; + _preview.invalidate(); + } + } + late TextEditingController sendToController; late TextEditingController cryptoAmountController; late TextEditingController baseAmountController; @@ -132,7 +166,12 @@ class _DesktopSendState extends ConsumerState { String? _onChainNote; Amount? _cachedAmountToSend; - String? _address; + String? _addressValue; + String? get _address => _addressValue; + set _address(String? value) { + if (_addressValue != value) _preview.invalidate(); + _addressValue = value; + } bool _addressToggleFlag = false; @@ -429,6 +468,38 @@ class _DesktopSendState extends ConsumerState { } Future previewSend() async { + final source = _address ?? ''; + final draftFee = customFeeRate; + final draftEthFee = _ethFee.value; + final draftBalance = ref.read(publicPrivateBalanceStateProvider); + + final wallet = ref.read(pWallets).getWallet(walletId); + FocusScope.of(context).unfocus(); + await _preview.run( + walletId: walletId, + source: source, + isCurrent: () => + mounted && + widget.walletId == walletId && + (_address ?? '') == source && + customFeeRate == draftFee && + _ethFee.value == draftEthFee && + ref.read(publicPrivateBalanceStateProvider) == draftBalance && + identical(ref.read(pWallets).getWallet(walletId), wallet), + work: _preparePreview, + onError: (error, stack) { + Logging.instance.e( + 'Send preview failed', + error: error, + stackTrace: stack, + ); + showTransactionFailedDialog(context, error, isDesktop: true); + }, + ); + } + + Future _preparePreview(SendPreviewAttempt attempt) async { + attempt.checkCurrent(); final nonceInput = _nonceInput; if (!nonceInput.isValid) return; final nonce = nonceInput.value; @@ -541,6 +612,7 @@ class _DesktopSendState extends ConsumerState { }, ); + attempt.checkCurrent(); if (shouldSendAll == null || shouldSendAll == false) { // cancel preview return; @@ -548,41 +620,31 @@ class _DesktopSendState extends ConsumerState { } } + attempt.checkCurrent(); + if (!mounted) return; + final progress = TransactionPreviewDialog(); + attempt.onClose(progress.close); try { - bool wasCancelled = false; - - if (mounted) { - unawaited( - showDialog( - context: context, - useSafeArea: false, - barrierDismissible: false, - builder: (context) { - return DesktopDialog( - maxWidth: 400, - maxHeight: double.infinity, - child: Padding( - padding: const EdgeInsets.all(32), - child: BuildingTransactionDialog( - coin: wallet.info.coin, - isSpark: - wallet is FiroWallet && - ref - .read(publicPrivateBalanceStateProvider.state) - .state == - BalanceType.private, - onCancel: () { - wasCancelled = true; - - Navigator.of(context).pop(); - }, - ), - ), - ); - }, + progress.show( + context, + (context) => DesktopDialog( + maxWidth: 400, + maxHeight: double.infinity, + child: Padding( + padding: const EdgeInsets.all(32), + child: BuildingTransactionDialog( + coin: wallet.info.coin, + isSpark: + wallet is FiroWallet && + ref.read(publicPrivateBalanceStateProvider) == + BalanceType.private, + closeOnCancel: false, + onCancel: attempt.cancel, + ), ), - ); - } + ), + attempt.cancel, + ); final time = Future.delayed(const Duration(milliseconds: 2500)); @@ -590,6 +652,18 @@ class _DesktopSendState extends ConsumerState { Future txDataFuture; final feeRateType = ref.read(feeRateTypeDesktopStateProvider); final satsPerVByte = feeRateType.customSatsPerVByte(customFeeRate); + final resolved = await attempt.resolve( + supportsOpenAlias: coin is Monero, + validateAddress: wallet.cryptoCurrency.validateAddress, + lookup: (input) => ref + .read(pSendOpenAliasService) + .resolve( + input, + validateAddress: wallet.cryptoCurrency.validateAddress, + ), + ); + attempt.checkCurrent(); + final destination = resolved.destination; if (isPaynymSend) { final paynymWallet = wallet as PaynymInterface; @@ -623,7 +697,7 @@ class _DesktopSendState extends ConsumerState { txData: TxData( sparkRecipients: [ ( - address: _address!, + address: destination, amount: amount, memo: memoController.text, isChange: false, @@ -643,11 +717,11 @@ class _DesktopSendState extends ConsumerState { txData: TxData( recipients: [ TxRecipient( - address: _address!, + address: destination, amount: amount, isChange: false, addressType: wallet.cryptoCurrency.getAddressType( - _address!, + destination, )!, ), ], @@ -671,18 +745,18 @@ class _DesktopSendState extends ConsumerState { ? null : [ TxRecipient( - address: _address!, + address: destination, amount: amount, isChange: false, addressType: wallet.cryptoCurrency.getAddressType( - _address!, + destination, )!, ), ], sparkRecipients: ref.read(pValidSparkSendToAddress) ? [ ( - address: _address!, + address: destination, amount: amount, memo: memoController.text, isChange: false, @@ -700,10 +774,10 @@ class _DesktopSendState extends ConsumerState { txData: TxData( recipients: [ TxRecipient( - address: _address!, + address: destination, amount: amount, isChange: false, - addressType: wallet.cryptoCurrency.getAddressType(_address!)!, + addressType: wallet.cryptoCurrency.getAddressType(destination)!, ), ], feeRateType: feeRateType, @@ -721,12 +795,13 @@ class _DesktopSendState extends ConsumerState { final memo = hasOptionalMemo ? memoController.text : null; txDataFuture = wallet.prepareSend( txData: TxData( + openAliasRecipient: resolved.alias, recipients: [ TxRecipient( - address: _address!, + address: destination, amount: amount, isChange: false, - addressType: wallet.cryptoCurrency.getAddressType(_address!)!, + addressType: wallet.cryptoCurrency.getAddressType(destination)!, ), ], memo: memo, @@ -749,7 +824,8 @@ class _DesktopSendState extends ConsumerState { txData = results.first as TxData; - if (!wasCancelled && mounted) { + attempt.checkCurrent(); + if (mounted) { if (isPaynymSend) { txData = txData.copyWith( paynymAccountLite: widget.accountLite!, @@ -764,8 +840,7 @@ class _DesktopSendState extends ConsumerState { txData = txData.copyWith(noteOnChain: _onChainNote ?? ""); } } - // pop building dialog - Navigator.of(context, rootNavigator: true).pop(); + progress.close(); unawaited( showDialog( @@ -784,74 +859,13 @@ class _DesktopSendState extends ConsumerState { ), ); } - } catch (e, s) { - Logging.instance.e("Desktop send: ", error: e, stackTrace: s); - if (mounted) { - // pop building dialog - Navigator.of(context, rootNavigator: true).pop(); - - unawaited( - showDialog( - context: context, - builder: (context) { - return DesktopDialog( - maxWidth: 450, - maxHeight: double.infinity, - child: Padding( - padding: const EdgeInsets.only(left: 32, bottom: 32), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "Transaction failed", - style: STextStyles.desktopH3(context), - ), - const DesktopDialogCloseButton(), - ], - ), - const SizedBox(height: 12), - Padding( - padding: const EdgeInsets.only(right: 32), - child: Text( - e.toString(), - textAlign: TextAlign.left, - style: STextStyles.desktopTextExtraExtraSmall(context) - .copyWith(fontSize: 18), - ), - ), - const SizedBox(height: 40), - Row( - children: [ - Expanded( - child: SecondaryButton( - buttonHeight: ButtonHeight.l, - label: "Ok", - onPressed: () { - Navigator.of( - context, - rootNavigator: true, - ).pop(); - }, - ), - ), - const SizedBox(width: 32), - ], - ), - ], - ), - ), - ); - }, - ), - ); - } + } finally { + progress.close(); } } void clearSendForm() { + _preview.invalidate(); if (!mounted) { return; } @@ -1267,6 +1281,18 @@ class _DesktopSendState extends ConsumerState { memoController = TextEditingController(); nonceController = TextEditingController(); + _preview.addListener(_previewChanged); + for (final controller in [ + sendToController, + cryptoAmountController, + baseAmountController, + memoController, + nonceController, + ]) { + controller.addListener(_previewInputChanged); + } + _previewInputChanged(); + onCryptoAmountChanged = _cryptoAmountChanged; cryptoAmountController.addListener(onCryptoAmountChanged); @@ -1326,8 +1352,16 @@ class _DesktopSendState extends ConsumerState { super.initState(); } + @override + void didUpdateWidget(covariant DesktopSend oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.walletId != widget.walletId) _preview.invalidate(); + } + @override void dispose() { + _preview.removeListener(_previewChanged); + _preview.dispose(); cryptoAmountController.removeListener(onCryptoAmountChanged); _ethFee.dispose(); @@ -1348,6 +1382,16 @@ class _DesktopSendState extends ConsumerState { @override Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType"); + ref.listen(pSendAmount, (previous, next) { + if (previous != next) _preview.invalidate(); + }); + ref.listen(pOpReturnData, (previous, next) { + if (previous != next) _preview.invalidate(); + }); + ref.listen(feeRateTypeDesktopStateProvider, (previous, next) { + if (previous != next) _preview.invalidate(); + }); + final String locale = ref.watch( localeServiceChangeNotifierProvider.select((value) => value.locale), ); @@ -1359,7 +1403,15 @@ class _DesktopSendState extends ConsumerState { 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. - final previewEnabled = ref.watch(pPreviewTxButtonEnabled(coin)); + final previewEnabled = + !_preview.busy && + widget.walletId == walletId && + ref.watch( + pPreviewTxButtonEnabledForDestination(( + coin: coin, + isAlias: _isOpenAliasInput, + )), + ); final needsEthFee = coin is Ethereum && isCustomFee; // add listener for epic cash to strip http:// and https:// prefixes if the address also ocntains an @ symbol (indicating an epicbox address) @@ -1781,14 +1833,19 @@ 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( + coin is Monero ? "Send to address or OpenAlias" : "Send to", + style: STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of(context) + .extension()! + .textFieldActiveSearchIconRight, + ), + textAlign: TextAlign.left, + ), + ], ), if (!isPaynymSend && !((isMimblewimblecoin || isEpiccash) && @@ -2005,7 +2062,8 @@ class _DesktopSendState extends ConsumerState { // For MWC/Epic slatepack transactions, address validation is not required. // TODO: When implementing encrypted slatepacks, address validation will be required. error = null; - } else if (!ref.watch(pValidSendToAddress)) { + } else if (!_isOpenAliasInput && + !ref.watch(pValidSendToAddress)) { error = "Invalid address"; } else { error = null; diff --git a/lib/providers/ui/preview_tx_button_state_provider.dart b/lib/providers/ui/preview_tx_button_state_provider.dart index 285d4b8781..5c2ea21a53 100644 --- a/lib/providers/ui/preview_tx_button_state_provider.dart +++ b/lib/providers/ui/preview_tx_button_state_provider.dart @@ -10,6 +10,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../services/openalias/open_alias_service.dart'; import '../../utilities/amount/amount.dart'; import '../../utilities/enums/epic_transaction_method.dart'; import '../../utilities/enums/mwc_transaction_method.dart'; @@ -49,50 +50,64 @@ final pIsSlatepack = Provider.family((ref, walletId) { return false; }); -final pPreviewTxButtonEnabled = Provider.autoDispose.family( - (ref, coin) { - final amount = ref.watch(pSendAmount) ?? Amount.zero; - final opReturnData = ref.watch(pOpReturnData); +final pPreviewTxButtonEnabled = Provider.autoDispose + .family((ref, coin) { + final amount = ref.watch(pSendAmount) ?? Amount.zero; + final opReturnData = ref.watch(pOpReturnData); - if (coin is! Firo && opReturnData != null) { - return false; - } + if (coin is! Firo && opReturnData != null) { + return false; + } - // For MWC slatepack transactions, address validation is not required. - if (coin is Mimblewimblecoin) { - final selectedMethod = ref.watch(pSelectedMwcTransactionMethod); - if (selectedMethod == MwcTransactionMethod.slatepack) { - return amount > Amount.zero; + // For MWC slatepack transactions, address validation is not required. + if (coin is Mimblewimblecoin) { + final selectedMethod = ref.watch(pSelectedMwcTransactionMethod); + if (selectedMethod == MwcTransactionMethod.slatepack) { + return amount > Amount.zero; + } } - } - // For Epic Cash slatepack transactions, address validation is not required. - if (coin is Epiccash) { - final selectedMethod = ref.watch(pSelectedEpicTransactionMethod); - if (selectedMethod == EpicTransactionMethod.slatepack) { - return amount > Amount.zero; + // For Epic Cash slatepack transactions, address validation is not required. + if (coin is Epiccash) { + final selectedMethod = ref.watch(pSelectedEpicTransactionMethod); + if (selectedMethod == EpicTransactionMethod.slatepack) { + return amount > Amount.zero; + } } - } - - if (coin is Firo) { - final firoType = ref.watch(publicPrivateBalanceStateProvider); - switch (firoType) { - case BalanceType.private: - return (ref.watch(pValidSendToAddress) || - ref.watch(pValidSparkSendToAddress)) && - !ref.watch(pIsExchangeAddress) && - opReturnData == null && - amount > Amount.zero; - - case BalanceType.public: - return ref.watch(pValidSendToAddress) && amount > Amount.zero; + + if (coin is Firo) { + final firoType = ref.watch(publicPrivateBalanceStateProvider); + switch (firoType) { + case BalanceType.private: + return (ref.watch(pValidSendToAddress) || + ref.watch(pValidSparkSendToAddress)) && + !ref.watch(pIsExchangeAddress) && + opReturnData == null && + amount > Amount.zero; + + case BalanceType.public: + return ref.watch(pValidSendToAddress) && amount > Amount.zero; + } + } else { + return ref.watch(pValidSendToAddress) && amount > Amount.zero; } - } else { - return ref.watch(pValidSendToAddress) && amount > Amount.zero; - } - }, -); + }); final previewTokenTxButtonStateProvider = StateProvider.autoDispose((_) { return false; }); + +// Alias eligibility is supplied by the individual send screen rather than +// stored in the shared literal-address validation provider. +final pPreviewTxButtonEnabledForDestination = Provider.autoDispose + .family((ref, input) { + if (input.coin is Monero && input.isAlias) { + return (ref.watch(pSendAmount) ?? Amount.zero) > Amount.zero && + ref.watch(pOpReturnData) == null; + } + return ref.watch(pPreviewTxButtonEnabled(input.coin)); + }); + +final pSendOpenAliasService = Provider( + (_) => OpenAliasService(), +); diff --git a/lib/services/openalias/dns_proxy_connection.dart b/lib/services/openalias/dns_proxy_connection.dart new file mode 100644 index 0000000000..3235cd6d44 --- /dev/null +++ b/lib/services/openalias/dns_proxy_connection.dart @@ -0,0 +1,114 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:async/async.dart'; + +import 'dns_proxy_socket.dart'; + +class DnsProxyConnection { + static const proxyConnectTimeout = Duration(seconds: 5); + + RawSocket? _transport; + DnsProxySocket? _socket; + final _result = Completer(); + bool _cancelled = false; + Future get socket => _result.future; + + DnsProxyConnection( + InternetAddress proxyHost, + int proxyPort, { + SecurityContext? context, + }) { + unawaited( + _connect(proxyHost, proxyPort, context).then( + (socket) { + if (_cancelled) { + socket.destroy(); + } else if (!_result.isCompleted) { + _result.complete(socket); + } + }, + onError: (Object error, StackTrace stack) { + if (!_result.isCompleted) _result.completeError(error, stack); + cancel(); + }, + ), + ); + } + + Future _connect( + InternetAddress host, + int port, + SecurityContext? context, + ) async { + try { + final raw = await RawSocket.connect( + host, + port, + timeout: proxyConnectTimeout, + ); + _transport = raw; + if (_cancelled) throw const SocketException('DNS connection cancelled'); + final transport = _socket = DnsProxySocket(raw); + final reader = ChunkedStreamReader(transport); + Future> read(int count) async { + final bytes = await reader.readBytes(count); + if (bytes.length != count) { + throw const SocketException('Incomplete SOCKS response'); + } + return bytes; + } + + transport.add([5, 1, 0]); + final greeting = await read(2); + if (greeting[0] != 5 || greeting[1] != 0) { + throw const SocketException('SOCKS authentication failed'); + } + transport.add([5, 1, 0, 1, 8, 8, 8, 8, 1, 187]); // CONNECT 8.8.8.8:443. + final reply = await read(4); + if (reply[0] != 5 || reply[1] != 0 || reply[2] != 0) { + throw const SocketException('SOCKS connection failed'); + } + switch (reply[3]) { + case 1: + await read(4); + case 4: + await read(16); + case 3: + await read((await read(1)).single); + default: + throw const SocketException('Invalid SOCKS address type'); + } + await read(2); + final subscription = await transport.detachForTls(); + await reader.cancel(); + if (_cancelled) throw const SocketException('DNS connection cancelled'); + final secured = await RawSecureSocket.secure( + raw, + subscription: subscription, + host: 'dns.google', + context: context, + ); + _socket = DnsProxySocket.secure(secured); + if (_cancelled) throw const SocketException('DNS connection cancelled'); + return _socket!; + } catch (_) { + _socket?.destroy(); + final transport = _transport; + if (transport != null) unawaited(transport.close()); + rethrow; + } + } + + void cancel() { + if (_cancelled) return; + _cancelled = true; + if (!_result.isCompleted) { + _result.completeError(const SocketException('DNS connection cancelled')); + } + _socket?.destroy(); + // This raw socket remains valid even while TLS owns its subscription. + final transport = _transport; + if (transport != null) unawaited(transport.close()); + } +} diff --git a/lib/services/openalias/dns_proxy_socket.dart b/lib/services/openalias/dns_proxy_socket.dart new file mode 100644 index 0000000000..1b88198aea --- /dev/null +++ b/lib/services/openalias/dns_proxy_socket.dart @@ -0,0 +1,209 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +// Adapts raw transports to HttpClient without giving up the cancellation handle +// during a TLS upgrade. Only detachForTls may transfer the raw subscription. +class DnsProxySocket extends Stream implements Socket { + final RawSocket _raw; + final _input = StreamController(); + final _closed = Completer(); + late final StreamSubscription _subscription; + late final _SocketConsumer _consumer; + late final IOSink _sink; + Completer? _writable; + bool _destroyed = false; + bool _detached = false; + Object? _error; + + DnsProxySocket(this._raw) { + _raw.readEventsEnabled = false; + _raw.writeEventsEnabled = false; + _input.onListen = () => _setReading(true); + _input.onPause = () => _setReading(false); + _input.onResume = () => _setReading(true); + _input.onCancel = () { + if (!_detached && !_destroyed) { + _raw.shutdown(SocketDirection.receive); + } + }; + _consumer = _SocketConsumer(this); + _sink = IOSink(_consumer); + // Errors are still available to flush/close callers; a remote disconnect + // must not also produce an unhandled error on an unobserved sink future. + _sink.done.ignore(); + _subscription = _raw.listen( + _onEvent, + onError: (Object error, StackTrace stack) { + _error = error; + if (!_input.isClosed) _input.addError(error, stack); + destroy(); + }, + onDone: destroy, + ); + } + + factory DnsProxySocket.secure(RawSecureSocket raw) = _DnsSecureSocket; + + void _setReading(bool enabled) { + if (!_detached && !_destroyed) _raw.readEventsEnabled = enabled; + } + + void _onEvent(RawSocketEvent event) { + if (event == RawSocketEvent.read) { + final bytes = _raw.read(); + if (bytes != null) _input.add(bytes); + } else if (event == RawSocketEvent.write) { + _writable?.complete(); + _writable = null; + } else if (event == RawSocketEvent.readClosed) { + unawaited(_input.close()); + } + } + + Future _write(List bytes) async { + var offset = 0; + while (offset < bytes.length) { + if (_destroyed || _detached) { + throw _error ?? const SocketException('DNS connection closed'); + } + offset += _raw.write(bytes, offset, bytes.length - offset); + if (offset < bytes.length) { + final writable = _writable = Completer(); + _raw.writeEventsEnabled = true; + await writable.future; + } + } + } + + Future _closeWrite() async { + if (!_destroyed && !_detached) _raw.shutdown(SocketDirection.send); + if (!_closed.isCompleted) _closed.complete(); + } + + Future> detachForTls() async { + await flush(); + if (_destroyed) throw const SocketException('DNS connection closed'); + _detached = true; + _raw.readEventsEnabled = false; + await _sink.close(); + unawaited(_input.close()); + return _subscription; + } + + @override + void destroy() { + if (_destroyed || _detached) return; + _destroyed = true; + _writable?.complete(); + _writable = null; + _consumer.stop(); + unawaited(_raw.close()); + unawaited(_subscription.cancel()); + unawaited(_input.close()); + if (!_closed.isCompleted) _closed.complete(); + } + + @override + StreamSubscription listen( + void Function(Uint8List)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) => _input.stream.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + + @override + void add(List data) => _sink.add(data); + @override + void addError(Object error, [StackTrace? stackTrace]) => + throw UnsupportedError('Cannot send errors on sockets'); + @override + Future addStream(Stream> stream) => _sink.addStream(stream); + @override + Future flush() => _sink.flush(); + @override + Future close() => _sink.close(); + @override + Future get done => _closed.future; + @override + Encoding get encoding => _sink.encoding; + @override + set encoding(Encoding value) => _sink.encoding = value; + @override + void write(Object? object) => _sink.write(object); + @override + void writeAll(Iterable objects, [String separator = '']) => + _sink.writeAll(objects, separator); + @override + void writeCharCode(int charCode) => _sink.writeCharCode(charCode); + @override + void writeln([Object? object = '']) => _sink.writeln(object); + @override + InternetAddress get address => _raw.address; + @override + InternetAddress get remoteAddress => _raw.remoteAddress; + @override + int get port => _raw.port; + @override + int get remotePort => _raw.remotePort; + @override + bool setOption(SocketOption option, bool enabled) => + _raw.setOption(option, enabled); + @override + Uint8List getRawOption(RawSocketOption option) => _raw.getRawOption(option); + @override + void setRawOption(RawSocketOption option) => _raw.setRawOption(option); +} + +class _SocketConsumer implements StreamConsumer> { + final DnsProxySocket socket; + StreamIterator>? _iterator; + + _SocketConsumer(this.socket); + + @override + Future addStream(Stream> stream) async { + final iterator = _iterator = StreamIterator(stream); + try { + while (await iterator.moveNext()) { + await socket._write(iterator.current); + } + } finally { + await iterator.cancel(); + _iterator = null; + } + } + + void stop() { + final iterator = _iterator; + if (iterator != null) unawaited(iterator.cancel()); + } + + @override + Future close() => socket._closeWrite(); +} + +class _DnsSecureSocket extends DnsProxySocket implements SecureSocket { + final RawSecureSocket _secure; + + _DnsSecureSocket(this._secure) : super(_secure); + + @override + X509Certificate? get peerCertificate => _secure.peerCertificate; + @override + String? get selectedProtocol => _secure.selectedProtocol; + @override + void renegotiate({ + bool useSessionCache = true, + bool requestClientCertificate = false, + bool requireClientCertificate = false, + }) { + // Like dart:io SecureSocket, renegotiation is not implemented. + } +} diff --git a/lib/services/openalias/doh_open_alias.dart b/lib/services/openalias/doh_open_alias.dart new file mode 100644 index 0000000000..a3f65781c0 --- /dev/null +++ b/lib/services/openalias/doh_open_alias.dart @@ -0,0 +1,198 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'dns_proxy_connection.dart'; +import 'open_alias.dart'; + +class DohOpenAlias { + static const directConnectTimeout = Duration(seconds: 5); + static const directLookupTimeout = Duration(seconds: 12); + // Over Tor the connection also waits for the exit stream and TLS via Tor. + static const torConnectTimeout = Duration(seconds: 10); + static const torLookupTimeout = Duration(seconds: 20); + + final Duration? connectTimeout; + final Duration? lookupTimeout; + + const DohOpenAlias({this.connectTimeout, this.lookupTimeout}); + + Future> lookup( + String domain, { + required ({InternetAddress host, int port})? proxyInfo, + }) async { + final viaTor = proxyInfo != null; + final client = HttpClient() + ..connectionTimeout = + connectTimeout ?? (viaTor ? torConnectTimeout : directConnectTimeout); + final tunnels = []; + try { + if (proxyInfo != null) { + client.connectionFactory = (uri, _, _) async { + final task = DnsProxyConnection(proxyInfo.host, proxyInfo.port); + tunnels.add(task); + return ConnectionTask.fromSocket(task.socket, task.cancel); + }; + } + return await _request(client, domain).timeout( + lookupTimeout ?? (viaTor ? torLookupTimeout : directLookupTimeout), + ); + } on OpenAliasException { + rethrow; + } catch (_) { + throw const OpenAliasException( + 'OpenAlias lookup failed. Check your connection and try again.', + ); + } finally { + client.close(force: true); + for (final task in tunnels) { + task.cancel(); + } + } + } + + Future> _request(HttpClient client, String domain) async { + final uri = Uri.https('dns.google', '/resolve', { + 'name': '$domain.', + 'type': 'TXT', + 'do': 'true', + 'cd': 'false', + 'edns_client_subnet': '0.0.0.0/0', + }); + final request = await client.getUrl(uri); + request.followRedirects = false; + request.headers.set('Accept', 'application/dns-json'); + final response = await request.close(); + if (response.statusCode != 200) { + throw const OpenAliasException( + 'The DNS resolver is unavailable. Try again later.', + ); + } + final bytes = []; + await for (final chunk in response) { + if (bytes.length + chunk.length > 65536) { + throw const OpenAliasException('The DNS response is too large.'); + } + bytes.addAll(chunk); + } + return decodeAuthenticatedDns(jsonDecode(utf8.decode(bytes)), domain); + } +} + +String _dnsName(Object? value) { + if (value is! String || value.contains('@')) { + throw const OpenAliasException('Invalid DNS response.'); + } + try { + return normalizeOpenAlias(value); + } on OpenAliasException { + throw const OpenAliasException('Invalid DNS response.'); + } +} + +List decodeAuthenticatedDns(Object? body, String domain) { + if (body is! Map) { + throw const OpenAliasException('Invalid DNS response.'); + } + if (body['Status'] == 3) { + throw OpenAliasException('No OpenAlias record exists for $domain.'); + } + if (body['Status'] != 0) { + throw OpenAliasException( + 'The DNS lookup for $domain failed. No address was accepted.', + ); + } + if (body['AD'] != true || body['CD'] != false || body['TC'] != false) { + throw const OpenAliasException( + 'DNSSEC verification failed or is unavailable. ' + 'Ask the recipient for a verified alias or address.', + ); + } + final questions = body['Question']; + if (questions is! List || + questions.length != 1 || + questions.single is! Map || + questions.single['type'] != 16 || + _dnsName(questions.single['name']) != domain) { + throw const OpenAliasException( + 'The DNS response does not match this alias.', + ); + } + final answers = body['Answer']; + if (answers == null) return []; + if (answers is! List || + answers.length > 64 || + answers.any((a) => a is! Map)) { + throw const OpenAliasException('Invalid DNS response.'); + } + final visited = {}; + var owner = domain; + for (var i = 0; i < 8; i++) { + if (!visited.add(owner)) { + throw const OpenAliasException('The alias has a DNS loop.'); + } + final atOwner = answers.where((a) => _dnsName(a['name']) == owner).toList(); + final cnames = atOwner.where((a) => a['type'] == 5).toList(); + final txt = atOwner.where((a) => a['type'] == 16).toList(); + if (cnames.isEmpty) { + return txt.map((a) { + if (a['data'] is! String || a['TTL'] is! int || (a['TTL'] as int) < 0) { + throw const OpenAliasException('Invalid TXT record.'); + } + return decodeDnsTxt(a['data'] as String); + }).toList(); + } + if (cnames.length != 1 || txt.isNotEmpty) { + throw const OpenAliasException('The DNS alias is ambiguous.'); + } + owner = _dnsName(cnames.single['data']); + } + throw const OpenAliasException('The DNS alias chain is too long.'); +} + +String decodeDnsTxt(String value) { + if (value.length > 8192) { + throw const OpenAliasException('The TXT record is too large.'); + } + if (!value.startsWith('"')) return value; + final out = StringBuffer(); + var i = 0; + while (i < value.length) { + while (i < value.length && value[i] == ' ') { + i++; + } + if (i == value.length) break; + if (value[i++] != '"') { + throw const OpenAliasException('Malformed DNS TXT encoding.'); + } + var closed = false; + while (i < value.length) { + final char = value[i++]; + if (char == '"') { + closed = true; + break; + } + if (char != '\\') { + out.write(char); + continue; + } + if (i >= value.length) { + throw const OpenAliasException('Malformed DNS TXT escape.'); + } + if (RegExp(r'[0-9]').hasMatch(value[i])) { + if (i + 3 > value.length) { + throw const OpenAliasException('Malformed DNS TXT escape.'); + } + final octet = int.tryParse(value.substring(i, i + 3)); + if (octet == null || octet > 255) { + throw const OpenAliasException('Malformed DNS TXT escape.'); + } + out.writeCharCode(octet); + i += 3; + } else { + out.write(value[i++]); + } + } + if (!closed) throw const OpenAliasException('Malformed DNS TXT encoding.'); + } + return out.toString(); +} diff --git a/lib/services/openalias/open_alias.dart b/lib/services/openalias/open_alias.dart new file mode 100644 index 0000000000..9b84f9c81a --- /dev/null +++ b/lib/services/openalias/open_alias.dart @@ -0,0 +1,153 @@ +class OpenAliasRecipient { + final String domain; + final String address; + // The alias as the user entered it, e.g. user@example.com. + final String displayAlias; + + const OpenAliasRecipient({ + required this.domain, + required this.address, + String? displayAlias, + }) : displayAlias = displayAlias ?? domain; +} + +String displayOpenAlias(String input) { + final name = input.trim().toLowerCase(); + return name.endsWith('.') ? name.substring(0, name.length - 1) : name; +} + +class OpenAliasException implements Exception { + final String message; + const OpenAliasException(this.message); + @override + String toString() => message; +} + +String normalizeOpenAlias(String input) { + var name = input.trim(); + if (name.codeUnits.any((code) => code > 127)) { + throw const OpenAliasException( + 'Use punycode for international domain names.', + ); + } + name = name.toLowerCase(); + if (name.split('@').length > 2) { + throw const OpenAliasException( + 'Enter a domain or an email-style OpenAlias.', + ); + } + name = name.replaceAll('@', '.'); + if (name.endsWith('.')) name = name.substring(0, name.length - 1); + // Underscores are valid in DNS names (RFC 2181), e.g. _service labels. + final label = RegExp(r'^[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?$'); + if (name.length > 253 || + !name.contains('.') || + !name.split('.').every(label.hasMatch) || + RegExp(r'^[0-9.]+$').hasMatch(name) || + name.endsWith('.onion')) { + throw const OpenAliasException( + 'Enter a valid domain. Use punycode for international names.', + ); + } + return name; +} + +Map? parseOpenAliasRecord(String record) { + if (record.length > 4096) { + throw const OpenAliasException('The OpenAlias record is too large.'); + } + final prefix = RegExp(r'^oa1:xmr(?:\s|$)').firstMatch(record); + if (prefix == null) return null; + final fields = {}; + final body = record.substring(prefix.end); + final parts = []; + var quoted = false; + var escaped = false; + var field = StringBuffer(); + for (final code in body.codeUnits) { + final char = String.fromCharCode(code); + if (code < 32 && char != '\t') { + throw const OpenAliasException('The OpenAlias record is malformed.'); + } + if (escaped) { + field.write(char); + escaped = false; + continue; + } + if (char == '\\') { + escaped = true; + continue; + } + if (char == '"') quoted = !quoted; + if (char == ';' && !quoted) { + parts.add(field.toString()); + field = StringBuffer(); + } else { + field.write(char); + } + } + if (quoted || escaped) { + throw const OpenAliasException('The OpenAlias record is malformed.'); + } + parts.add(field.toString()); + for (final part in parts) { + if (part.trim().isEmpty) continue; + final equals = part.indexOf('='); + if (equals < 1) { + throw const OpenAliasException('The OpenAlias record is malformed.'); + } + final key = part.substring(0, equals).trim(); + var value = part.substring(equals + 1).trim(); + if (!RegExp(r'^[a-z_]+$').hasMatch(key) || fields.containsKey(key)) { + throw const OpenAliasException( + 'The OpenAlias record has duplicate or invalid fields.', + ); + } + if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) { + value = value.substring(1, value.length - 1); + } else if (value.contains('"')) { + throw const OpenAliasException('The OpenAlias record is malformed.'); + } + fields[key] = value; + } + return fields; +} + +OpenAliasRecipient selectOpenAliasRecipient({ + required String domain, + required List records, + required bool Function(String) validateAddress, +}) { + if (records.length > 64) { + throw const OpenAliasException('Too many DNS records.'); + } + final addresses = {}; + for (final record in records) { + final fields = parseOpenAliasRecord(record); + if (fields == null) continue; + if (fields['tx_payment_id']?.isNotEmpty ?? false) { + throw const OpenAliasException( + 'This alias requires a separate payment ID, which is not supported.', + ); + } + final address = fields['recipient_address']; + if (address == null || !validateAddress(address)) { + throw const OpenAliasException( + 'This alias contains an invalid Monero address for this wallet.', + ); + } + addresses.add(address); + } + if (addresses.isEmpty) { + throw OpenAliasException( + 'No Monero OpenAlias record was found for $domain.', + ); + } + if (addresses.length != 1) { + throw const OpenAliasException( + 'This alias has multiple Monero addresses. ' + 'Ask the recipient for an address.', + ); + } + return OpenAliasRecipient(domain: domain, address: addresses.single); +} diff --git a/lib/services/openalias/open_alias_service.dart b/lib/services/openalias/open_alias_service.dart new file mode 100644 index 0000000000..318a49394e --- /dev/null +++ b/lib/services/openalias/open_alias_service.dart @@ -0,0 +1,68 @@ +import '../../app_config.dart'; +import '../../utilities/prefs.dart'; +import '../tor_service.dart'; +import 'doh_open_alias.dart'; +import 'open_alias.dart'; + +class OpenAliasService { + final bool Function() _externalCalls; + final bool Function() _useTor; + final bool Function() _supportsTor; + final Future> Function(String, bool) _lookup; + + OpenAliasService({ + bool Function()? externalCalls, + bool Function()? useTor, + bool Function()? supportsTor, + Future> Function(String, bool)? lookup, + }) : _externalCalls = externalCalls ?? (() => Prefs.instance.externalCalls), + _useTor = useTor ?? (() => Prefs.instance.useTor), + _supportsTor = + supportsTor ?? (() => AppConfig.hasFeature(AppFeature.tor)), + _lookup = lookup ?? _lookupDns; + + static Future> _lookupDns(String domain, bool useTor) => + const DohOpenAlias().lookup( + domain, + proxyInfo: useTor ? TorService.sharedInstance.getProxyInfo() : null, + ); + + Future resolve( + String input, { + required bool Function(String) validateAddress, + }) async { + final domain = normalizeOpenAlias(input); + if (!_externalCalls()) { + throw const OpenAliasException( + 'OpenAlias lookups are off in Incognito mode. To use OpenAlias, ' + 'switch ${AppConfig.prefix} Experience to Easy Crypto in Advanced ' + 'settings.', + ); + } + final useTor = _useTor(); + if (useTor && !_supportsTor()) { + throw const OpenAliasException('Tor is unavailable. No lookup was sent.'); + } + try { + final records = await _lookup(domain, useTor); + if (_useTor() != useTor || !_externalCalls()) { + throw const OpenAliasException( + 'Privacy settings changed. ' + 'Please look up the alias again.', + ); + } + return selectOpenAliasRecipient( + domain: domain, + records: records, + validateAddress: validateAddress, + ); + } on OpenAliasException { + rethrow; + } catch (_) { + throw const OpenAliasException( + 'OpenAlias lookup is unavailable. ' + 'Check your connection and Tor settings.', + ); + } + } +} diff --git a/lib/services/openalias/send_preview.dart b/lib/services/openalias/send_preview.dart new file mode 100644 index 0000000000..667dfb6b2a --- /dev/null +++ b/lib/services/openalias/send_preview.dart @@ -0,0 +1,142 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import 'open_alias.dart'; +import 'send_recipient.dart'; + +class ResolvedSendRecipient { + final String destination; + final OpenAliasRecipient? alias; + + const ResolvedSendRecipient(this.destination, [this.alias]); +} + +// One instance per send screen, never shared between wallets. Invalidating an +// attempt prevents late lookup/preparation results from reaching confirmation. +class SendPreview extends ChangeNotifier { + SendPreviewAttempt? _active; + bool _disposed = false; + + bool get busy => _active != null; + + Future run({ + required String walletId, + required String source, + required bool Function() isCurrent, + required Future Function(SendPreviewAttempt) work, + required void Function(Object, StackTrace) onError, + }) async { + if (_disposed || busy || !isCurrent()) return; + final attempt = SendPreviewAttempt._(this, walletId, source, isCurrent); + _active = attempt; + notifyListeners(); + try { + await work(attempt); + } on _StalePreview { + // A new edit, cancellation, wallet, or screen owns the user's intent. + } catch (error, stack) { + if (attempt.isCurrent) onError(error, stack); + } finally { + attempt._close(); + if (identical(_active, attempt)) { + _active = null; + if (!_disposed) notifyListeners(); + } + } + } + + void invalidate() { + final attempt = _active; + _active = null; + attempt?._close(); + if (attempt != null && !_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + invalidate(); + super.dispose(); + } +} + +class SendPreviewAttempt { + final SendPreview _owner; + final String walletId; + final String source; + final bool Function() _isCurrent; + final _cleanup = []; + + SendPreviewAttempt._( + this._owner, + this.walletId, + this.source, + this._isCurrent, + ); + + bool get isCurrent => + !_owner._disposed && identical(_owner._active, this) && _isCurrent(); + + void checkCurrent() { + if (!isCurrent) throw const _StalePreview(); + } + + void cancel() { + if (identical(_owner._active, this)) _owner.invalidate(); + } + + void onClose(VoidCallback cleanup) { + checkCurrent(); + _cleanup.add(cleanup); + } + + void _close() { + for (final cleanup in _cleanup) { + cleanup(); + } + _cleanup.clear(); + } + + Future resolve({ + required bool supportsOpenAlias, + required bool Function(String) validateAddress, + required Future Function(String) lookup, + }) async { + checkCurrent(); + // Non-Monero destinations retain their existing coin-specific handling + // (including Spark, PayNym and slatepack). + if (!supportsOpenAlias) return ResolvedSendRecipient(source); + final input = SendRecipient.classify( + source, + supportsOpenAlias: true, + validateAddress: validateAddress, + ); + if (input.kind == SendRecipientKind.literal) { + return ResolvedSendRecipient(input.destination); + } + if (!input.isAlias) { + throw const OpenAliasException('Enter a valid address or OpenAlias.'); + } + final recipient = await lookup(input.destination); + checkCurrent(); + if (!validateAddress(recipient.address) || + recipient.domain != input.destination) { + throw const OpenAliasException( + 'OpenAlias did not return a valid recipient for this wallet.', + ); + } + return ResolvedSendRecipient( + recipient.address, + OpenAliasRecipient( + domain: recipient.domain, + address: recipient.address, + displayAlias: displayOpenAlias(source), + ), + ); + } +} + +class _StalePreview implements Exception { + const _StalePreview(); +} diff --git a/lib/services/openalias/send_recipient.dart b/lib/services/openalias/send_recipient.dart new file mode 100644 index 0000000000..b6df0bf407 --- /dev/null +++ b/lib/services/openalias/send_recipient.dart @@ -0,0 +1,36 @@ +import 'open_alias.dart'; + +enum SendRecipientKind { literal, openAlias, invalid } + +// Local send-field classification only. Literal-address validators remain +// unchanged, and an alias is not a usable transaction recipient until resolved. +class SendRecipient { + final SendRecipientKind kind; + final String destination; + + const SendRecipient._(this.kind, this.destination); + + factory SendRecipient.classify( + String input, { + required bool supportsOpenAlias, + required bool Function(String) validateAddress, + }) { + final destination = input.trim(); + if (validateAddress(destination)) { + return SendRecipient._(SendRecipientKind.literal, destination); + } + if (supportsOpenAlias) { + try { + return SendRecipient._( + SendRecipientKind.openAlias, + normalizeOpenAlias(destination), + ); + } on OpenAliasException { + // Invalid input stays editable without initiating a lookup. + } + } + return SendRecipient._(SendRecipientKind.invalid, destination); + } + + bool get isAlias => kind == SendRecipientKind.openAlias; +} diff --git a/lib/utilities/util.dart b/lib/utilities/util.dart index 1e3f612520..53fd0e5970 100644 --- a/lib/utilities/util.dart +++ b/lib/utilities/util.dart @@ -33,6 +33,10 @@ abstract class Util { static double? screenWidth; static bool? isIpad; + /// Overrides layout selection in debug builds for host-independent tests. + @visibleForTesting + static bool? debugIsDesktopOverride; + static NumberSymbols? getSymbolsFor({required String locale}) { return _numberSymbolsCache.putIfAbsent(locale, () { final exactSymbols = numberFormatSymbols[locale]; @@ -84,6 +88,15 @@ abstract class Util { } static bool get isDesktop { + bool? override; + assert(() { + override = debugIsDesktopOverride; + return true; + }()); + if (override != null) { + return override!; + } + // special check for running on linux based phones if (Platform.isLinux && screenWidth != null && screenWidth! < 800) { return false; diff --git a/lib/wallets/models/tx_data.dart b/lib/wallets/models/tx_data.dart index 1c8f81c931..539f306c8b 100644 --- a/lib/wallets/models/tx_data.dart +++ b/lib/wallets/models/tx_data.dart @@ -7,6 +7,7 @@ import 'package:web3dart/web3dart.dart' as web3dart; import '../../models/input.dart'; import '../../models/isar/models/blockchain_data/v2/transaction_v2.dart'; import '../../models/isar/models/isar_models.dart'; +import '../../services/openalias/open_alias.dart'; import '../../models/paynym/paynym_account_lite.dart'; import '../../utilities/amount/amount.dart'; import '../../utilities/enums/fee_rate_type_enum.dart'; @@ -53,6 +54,12 @@ class TxData { final String? memo; final List? recipients; + final OpenAliasRecipient? _openAliasRecipient; + OpenAliasRecipient? get openAliasRecipient => + recipients?.length == 1 && + recipients!.single.address == _openAliasRecipient?.address + ? _openAliasRecipient + : null; final Set? utxos; final List? usedUTXOs; @@ -130,6 +137,7 @@ class TxData { this.noteOnChain, this.memo, this.recipients, + this._openAliasRecipient, this.utxos, this.usedUTXOs, this.changeAddress, @@ -275,6 +283,7 @@ class TxData { Set? utxos, List? usedUTXOs, List? recipients, + OpenAliasRecipient? openAliasRecipient, String? frostMSConfig, List? frostSigners, String? changeAddress, @@ -329,6 +338,7 @@ class TxData { utxos: utxos ?? this.utxos, usedUTXOs: usedUTXOs ?? this.usedUTXOs, recipients: recipients ?? this.recipients, + openAliasRecipient: openAliasRecipient ?? this.openAliasRecipient, frostMSConfig: frostMSConfig ?? this.frostMSConfig, frostSigners: frostSigners ?? this.frostSigners, changeAddress: changeAddress ?? this.changeAddress, diff --git a/lib/widgets/transaction_preview_dialog.dart b/lib/widgets/transaction_preview_dialog.dart new file mode 100644 index 0000000000..0346ce248e --- /dev/null +++ b/lib/widgets/transaction_preview_dialog.dart @@ -0,0 +1,129 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../services/openalias/open_alias.dart'; +import '../themes/stack_colors.dart'; +import '../utilities/text_styles.dart'; +import 'desktop/desktop_dialog.dart'; +import 'desktop/desktop_dialog_close_button.dart'; +import 'desktop/secondary_button.dart'; +import 'stack_dialog.dart'; + +// Removes only the route created by this attempt, even after cancellation or +// when another route becomes current. Never blindly pops the send view. +class TransactionPreviewDialog { + DialogRoute? _route; + + void show( + BuildContext context, + WidgetBuilder builder, + VoidCallback onCancel, + ) { + final navigator = Navigator.of(context, rootNavigator: true); + final route = DialogRoute( + context: context, + barrierDismissible: false, + useSafeArea: false, + builder: (context) => PopScope(canPop: false, child: builder(context)), + ); + _route = route; + unawaited( + navigator.push(route).then((_) { + if (identical(_route, route)) { + _route = null; + onCancel(); + } + }), + ); + } + + void close() { + final route = _route; + _route = null; + if (route != null && route.isActive) { + route.navigator?.removeRoute(route); + } + } +} + +void showTransactionFailedDialog( + BuildContext context, + Object error, { + required bool isDesktop, +}) { + // No transaction was built when the recipient could not be resolved. + final title = error is OpenAliasException + ? 'OpenAlias lookup failed' + : 'Transaction failed'; + unawaited( + showDialog( + context: context, + useSafeArea: false, + builder: (context) { + if (!isDesktop) { + return StackDialog( + title: title, + message: error.toString(), + rightButton: TextButton( + style: Theme.of(context) + .extension()! + .getSecondaryEnabledButtonStyle(context), + onPressed: () => Navigator.of(context).pop(), + child: Text( + 'Ok', + style: STextStyles.button(context).copyWith( + color: Theme.of(context) + .extension()! + .accentColorDark, + ), + ), + ), + ); + } + return DesktopDialog( + maxWidth: 450, + maxHeight: double.infinity, + child: Padding( + padding: const EdgeInsets.only(left: 32, bottom: 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(title, style: STextStyles.desktopH3(context)), + const DesktopDialogCloseButton(), + ], + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.only(right: 32), + child: Text( + error.toString(), + textAlign: TextAlign.left, + style: STextStyles.desktopTextExtraExtraSmall(context) + .copyWith(fontSize: 18), + ), + ), + const SizedBox(height: 40), + Row( + children: [ + Expanded( + child: SecondaryButton( + buttonHeight: ButtonHeight.l, + label: 'Ok', + onPressed: () => Navigator.of(context).pop(), + ), + ), + const SizedBox(width: 32), + ], + ), + ], + ), + ), + ); + }, + ), + ); +} diff --git a/test/pages/send_view/open_alias_preview_test.dart b/test/pages/send_view/open_alias_preview_test.dart new file mode 100644 index 0000000000..ed491554fc --- /dev/null +++ b/test/pages/send_view/open_alias_preview_test.dart @@ -0,0 +1,514 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:stackwallet/models/send_view_auto_fill_data.dart'; +import 'package:stackwallet/services/openalias/open_alias.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/balance.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/address.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/models/paymint/fee_object_model.dart'; +import 'package:stackwallet/pages/send_view/send_view.dart'; +import 'package:stackwallet/pages/send_view/confirm_transaction_view.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart'; +import 'package:stackwallet/providers/providers.dart'; +import 'package:stackwallet/providers/ui/preview_tx_button_state_provider.dart'; +import 'package:stackwallet/services/openalias/open_alias_service.dart'; +import 'package:stackwallet/services/wallets.dart'; +import 'package:stackwallet/themes/coin_icon_provider.dart'; +import 'package:stackwallet/themes/coin_image_provider.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/themes/theme_providers.dart'; +import 'package:stackwallet/utilities/amount/amount.dart'; +import 'package:stackwallet/utilities/amount/amount_unit.dart'; +import 'package:stackwallet/utilities/prefs.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/isar/models/wallet_info.dart'; +import 'package:stackwallet/wallets/isar/providers/wallet_info_provider.dart'; +import 'package:stackwallet/wallets/models/tx_data.dart'; +import 'package:stackwallet/wallets/wallet/intermediate/cryptonote_wallet.dart'; +import 'package:stackwallet/wallets/wallet/wallet.dart'; + +import '../../sample_data/theme_json.dart'; + +const literal = + '4AeRgkWZsMJhAWKMeCZ3h4ZSPnAcW5VBtRFyLd6gBEf6GgJU2FH' + 'XDA6i1DnQTd6h8R3VU5AkbGcWSNhtSwNNPgaD48gp4nn'; +Amount amount(int value) => + Amount(rawValue: BigInt.from(value), fractionDigits: 12); + +class _Monero extends Monero { + _Monero() : super(CryptoCurrencyNetwork.main); + @override + bool validateAddress(String address) => + address == literal || address == 'second-recipient'; + @override + AddressType? getAddressType(String address) { + if (!validateAddress(address)) { + throw StateError('Unresolved recipient: $address'); + } + return AddressType.cryptonote; + } +} + +class _Info extends Fake implements WalletInfo { + _Info(this.coin); + @override + final CryptoCurrency coin; + @override + String get walletId => 'wallet'; + @override + String get name => 'Test wallet'; + @override + bool get isMwebEnabled => false; + @override + Balance get cachedBalance => Balance( + total: amount(10000000000000), + spendable: amount(10000000000000), + blockedTotal: amount(0), + pendingSpendable: amount(0), + ); +} + +class _Wallet extends Fake implements CryptonoteWallet { + _Wallet(this.cryptoCurrency) : info = _Info(cryptoCurrency); + @override + final Monero cryptoCurrency; + @override + final WalletInfo info; + final prepared = []; + Completer? preparation; + final sent = []; + @override + Future confirmSend({required TxData txData}) async { + sent.add(txData); + throw StateError('simulated broadcast failure'); + } + + @override + Future get fees async => FeeObject( + numberOfBlocksFast: 1, + numberOfBlocksAverage: 2, + numberOfBlocksSlow: 3, + fast: BigInt.one, + medium: BigInt.one, + slow: BigInt.one, + ); + @override + int getTxPriorityHigh() => 3; + @override + int getTxPriorityMedium() => 2; + @override + int getTxPriorityNormal() => 1; + @override + Future estimateFeeFor(Amount value, BigInt feeRate) async => + amount(10); + @override + Future prepareSend({required TxData txData}) async { + prepared.add(txData); + return preparation?.future ?? txData.copyWith(fee: amount(10)); + } +} + +class _Wallets extends Fake implements Wallets { + _Wallets(this.wallet); + final Wallet wallet; + @override + Wallet getWallet(String walletId) => wallet; +} + +class _Prefs extends ChangeNotifier implements Prefs { + @override + String get currency => 'USD'; + @override + bool get externalCalls => false; + @override + bool get enableCoinControl => false; + @override + AmountUnit amountUnit(CryptoCurrency coin) => AmountUnit.normal; + @override + int maxDecimals(CryptoCurrency coin) => coin.fractionDigits; + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _AuthObserver extends NavigatorObserver { + bool approveNext = false; + @override + void didPush(Route route, Route? previousRoute) { + if (approveNext) { + approveNext = false; + // Supply the authentication result without involving PIN/password storage. + scheduleMicrotask(() => navigator!.pop(true)); + } + } +} + +class _Harness { + final bool desktop; + final _Monero coin = _Monero(); + late final _Wallet wallet = _Wallet(coin); + late final ProviderContainer container; + final navigator = GlobalKey(); + final identity = ValueNotifier('wallet'); + final visible = ValueNotifier(true); + final auth = _AuthObserver(); + final lookups = >>[]; + int get calls => lookups.length; + _Harness(this.desktop); + + Future mount( + WidgetTester tester, { + SendViewAutoFillData? autofill, + }) async { + final size = desktop ? const Size(1200, 900) : const Size(390, 844); + final previousLayout = Util.debugIsDesktopOverride; + final previousWidth = Util.screenWidth; + Util.debugIsDesktopOverride = desktop; + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + Util.screenWidth = size.width; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(() { + Util.debugIsDesktopOverride = previousLayout; + Util.screenWidth = previousWidth; + }); + expect(Util.isDesktop, desktop); + final theme = StackTheme.fromJson(json: lightThemeJsonMap); + container = ProviderContainer( + overrides: [ + themeProvider.overrideWithProvider(StateProvider((_) => theme)), + pWallets.overrideWithValue(_Wallets(wallet)), + for (final id in ['wallet', 'other-wallet']) ...[ + pWalletInfo(id).overrideWithValue(wallet.info), + pWalletCoin(id).overrideWithValue(coin), + pWalletName(id).overrideWithValue('Test wallet'), + pWalletBalance(id).overrideWithValue(wallet.info.cachedBalance), + ], + prefsChangeNotifierProvider.overrideWithValue(_Prefs()), + coinIconProvider(coin) + .overrideWithValue('test/sample_data/light/assets/dummy.svg'), + coinImageSecondaryProvider(coin).overrideWithValue('dummy.svg'), + pSendOpenAliasService.overrideWithValue( + OpenAliasService( + externalCalls: () => true, + useTor: () => false, + lookup: (domain, _) { + if (domain != 'alice.example') { + throw StateError('Unexpected lookup: $domain'); + } + final pending = Completer>(); + lookups.add(pending); + return pending.future; + }, + ), + ), + ], + ); + addTearDown(() async { + await tester.pumpWidget(const SizedBox()); + await tester.pumpAndSettle(); + container.dispose(); + identity.dispose(); + visible.dispose(); + }); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + navigatorKey: navigator, + navigatorObservers: [auth], + theme: ThemeData( + extensions: [StackColors.fromStackColorTheme(theme)], + ), + home: const Scaffold(body: Text('Home')), + ), + ), + ); + unawaited( + navigator.currentState!.push( + MaterialPageRoute( + builder: (_) => ValueListenableBuilder( + valueListenable: visible, + builder: (_, show, _) => !show + ? const SizedBox() + : ValueListenableBuilder( + valueListenable: identity, + builder: (_, id, _) => desktop + ? Scaffold( + body: SingleChildScrollView( + child: DesktopSend( + walletId: id, + autoFillData: autofill, + ), + ), + ) + : SendView( + walletId: id, + coin: coin, + autoFillData: autofill, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + } + + Finder get field => find.byKey(const Key('sendViewAddressFieldKey')); + Finder get button => find.text(desktop ? 'Preview send' : 'Preview'); + String source(WidgetTester tester) => + tester.widget(field).controller!.text; + bool enabled(WidgetTester tester) => desktop + ? tester + .widget( + find.ancestor(of: button, matching: find.byType(PrimaryButton)), + ) + .enabled + : tester + .widget( + find.ancestor(of: button, matching: find.byType(TextButton)), + ) + .onPressed != + null; + + Future enter(WidgetTester tester, String destination) async { + await tester.enterText(field, destination); + await tester.pumpAndSettle(); + } + + Future setAmount(WidgetTester tester) async { + container.read(pSendAmount.notifier).state = amount(1000000000000); + await tester.pumpAndSettle(); + } + + Future preview(WidgetTester tester, {bool twice = false}) async { + await tester.ensureVisible(button); + if (twice) { + final callback = desktop + ? tester + .widget( + find.ancestor( + of: button, + matching: find.byType(PrimaryButton), + ), + ) + .onPressed! + : tester + .widget( + find.ancestor(of: button, matching: find.byType(TextButton)), + ) + .onPressed!; + callback(); + callback(); + } else { + await tester.tap(button); + } + await tester.pump(const Duration(milliseconds: 150)); + await tester.pump(); + } + + Future finish(WidgetTester tester) async { + await tester.pump(); + await tester.pump(const Duration(seconds: 3)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + } + + void succeed([String destination = literal]) => + lookups.last.complete(['oa1:xmr recipient_address=$destination;']); +} + +void main() { + for (final desktop in [false, true]) { + final layout = desktop ? 'desktop 1200x900' : 'mobile 390x844'; + testWidgets( + '$layout resolves on Preview and shows each prepared recipient', + (tester) async { + final h = _Harness(desktop); + await h.mount(tester); + await h.enter(tester, 'Alice@Example'); + expect(h.calls, 0); + expect(h.enabled(tester), isFalse); + expect(h.container.read(pValidSendToAddress), isFalse); + expect(find.text('Invalid address'), findsNothing); + expect(find.text('Use OpenAlias'), findsNothing); + await h.setAmount(tester); + expect(h.enabled(tester), isTrue); + expect(h.calls, 0); // Amount changes and fee estimation are local. + await h.preview(tester, twice: true); + expect(h.calls, 1); + expect(h.wallet.prepared, isEmpty); + h.succeed(); + await h.finish(tester); + expect(h.wallet.prepared.single.recipients!.single.address, literal); + expect( + h.wallet.prepared.single.openAliasRecipient!.domain, + 'alice.example', + ); + expect(find.text('alice.example'), findsNothing); + expect(find.byType(ConfirmTransactionView), findsOneWidget); + expect(find.text('alice@example'), findsOneWidget); + expect(find.text(literal), findsOneWidget); + h.navigator.currentState!.pop(); + await tester.pumpAndSettle(); + expect(h.source(tester), 'Alice@Example'); + await h.preview(tester); + expect(h.calls, 2); + h.succeed('second-recipient'); + await h.finish(tester); + expect( + h.wallet.prepared.last.recipients!.single.address, + 'second-recipient', + ); + expect(find.text('second-recipient'), findsOneWidget); + expect(find.text('alice@example'), findsOneWidget); + // Final send receives the prepared transaction, without another lookup. + h.auth.approveNext = true; + await tester.ensureVisible(find.text('Send')); + await tester.tap(find.text('Send')); + await h.finish(tester); + expect( + h.wallet.sent.single.recipients!.single.address, + 'second-recipient', + ); + expect( + h.wallet.sent.single.openAliasRecipient!.domain, + 'alice.example', + ); + expect(h.calls, 2); + }, + ); + + testWidgets('$layout lookup failure retains input and the send route', ( + tester, + ) async { + final h = _Harness(desktop); + await h.mount(tester); + await h.enter(tester, 'Alice@Example'); + await h.setAmount(tester); + await h.preview(tester); + h.lookups.single.completeError( + const OpenAliasException('DNSSEC verification failed.'), + ); + await h.finish(tester); + expect(h.wallet.prepared, isEmpty); + expect(find.byType(ConfirmTransactionView), findsNothing); + expect(find.text('OpenAlias lookup failed'), findsOneWidget); + expect(find.text('Transaction failed'), findsNothing); + expect(find.text('DNSSEC verification failed.'), findsOneWidget); + await tester.tap(find.text('Ok')); + await tester.pumpAndSettle(); + expect(h.source(tester), 'Alice@Example'); + expect(h.navigator.currentState!.canPop(), isTrue); + expect(h.enabled(tester), isTrue); + }); + + for (final change in [ + 'edit away and back', + 'amount', + 'cancel', + 'wallet', + 'dispose', + ]) { + testWidgets('$layout $change discards a pending lookup', (tester) async { + final h = _Harness(desktop); + await h.mount(tester); + await h.enter(tester, 'Alice@Example'); + await h.setAmount(tester); + await h.preview(tester); + expect(h.calls, 1); + switch (change) { + case 'edit away and back': + final controller = tester.widget(h.field).controller!; + controller.text = 'bob.example'; + controller.text = 'Alice@Example'; + case 'amount': + h.container.read(pSendAmount.notifier).state = amount(2); + case 'cancel': + await tester.tap(find.text('Cancel')); + case 'wallet': + h.identity.value = 'other-wallet'; + await tester.pump(); + h.identity.value = 'wallet'; + await tester.pump(); + case 'dispose': + h.visible.value = false; + await tester.pump(); + } + h.succeed(); + await h.finish(tester); + expect(h.wallet.prepared, isEmpty); + expect(find.byType(ConfirmTransactionView), findsNothing); + expect(find.text('Transaction failed'), findsNothing); + expect(find.text('OpenAlias lookup failed'), findsNothing); + expect(h.navigator.currentState!.canPop(), isTrue); + }); + } + + for (final fail in [false, true]) { + testWidgets('$layout cancellation during preparation ignores late ' + '${fail ? 'error' : 'success'}', (tester) async { + final h = _Harness(desktop); + await h.mount(tester); + h.wallet.preparation = Completer(); + await h.enter(tester, 'Alice@Example'); + await h.setAmount(tester); + await h.preview(tester); + h.succeed(); + await tester.pump(); + expect(h.wallet.prepared, hasLength(1)); + await tester.tap(find.text('Cancel')); + if (fail) { + h.wallet.preparation!.completeError( + StateError('late preparation failure'), + ); + } else { + h.wallet.preparation!.complete( + h.wallet.prepared.single.copyWith(fee: amount(10)), + ); + } + await h.finish(tester); + expect(find.byType(ConfirmTransactionView), findsNothing); + expect(find.text('Transaction failed'), findsNothing); + expect(h.source(tester), 'Alice@Example'); + expect(h.navigator.currentState!.canPop(), isTrue); + }); + } + for (final input in ['literal', 'contact', 'uri']) { + testWidgets('$layout $input prepares a literal address without lookup', ( + tester, + ) async { + final h = _Harness(desktop); + await h.mount( + tester, + autofill: input == 'contact' + ? SendViewAutoFillData( + address: literal, + contactLabel: 'Alice contact', + ) + : null, + ); + if (input != 'contact') { + await h.enter( + tester, + input == 'uri' ? 'monero:$literal?amount=1' : literal, + ); + } + await h.setAmount(tester); + expect(h.enabled(tester), isTrue); + await h.preview(tester); + await h.finish(tester); + expect(h.calls, 0); + expect(h.wallet.prepared.single.recipients!.single.address, literal); + expect(h.wallet.prepared.single.openAliasRecipient, isNull); + }); + } + } +} diff --git a/test/services/openalias/doh_open_alias_test.dart b/test/services/openalias/doh_open_alias_test.dart new file mode 100644 index 0000000000..f229a827f9 --- /dev/null +++ b/test/services/openalias/doh_open_alias_test.dart @@ -0,0 +1,165 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/services/openalias/open_alias.dart'; +import 'package:stackwallet/services/openalias/doh_open_alias.dart'; + +Map reply({ + bool ad = true, + List>? answers, +}) => { + 'Status': 0, + 'AD': ad, + 'CD': false, + 'TC': false, + 'Question': [ + {'name': 'alice.example.', 'type': 16}, + ], + 'Answer': + answers ?? [txt('alice.example.', 'oa1:xmr recipient_address=valid;')], +}; +Map txt(String name, String data) => { + 'name': name, + 'type': 16, + 'TTL': 60, + 'data': data, +}; +OpenAliasRecipient select(List records) => selectOpenAliasRecipient( + domain: 'alice.example', + records: records, + validateAddress: (s) => s == 'valid' || s == 'second', +); + +void main() { + test('joins TXT chunks within one record, with presentation escapes', () { + expect( + decodeDnsTxt(r'"oa1:xmr recipient_address=va" "lid;"'), + 'oa1:xmr recipient_address=valid;', + ); + expect(decodeDnsTxt(r'"a\032b\"c"'), 'a b"c'); + expect( + decodeDnsTxt('oa1:xmr recipient_address=valid;'), + 'oa1:xmr recipient_address=valid;', + ); + }); + test('malformed TXT presentation cannot become an address', () { + for (final input in ['"unterminated', '"one" junk', r'"\999"']) { + expect(() => decodeDnsTxt(input), throwsA(isA())); + } + }); + test( + 'accepts only authenticated, complete answers to the exact question', + () { + expect(decodeAuthenticatedDns(reply(), 'alice.example'), hasLength(1)); + for (final altered in [ + reply(ad: false), + reply()..remove('AD'), + reply()..['CD'] = true, + reply()..['TC'] = true, + reply()..['Status'] = 2, + reply() + ..['Question'] = [ + {'name': 'attacker.example.', 'type': 16}, + ], + ]) { + expect( + () => decodeAuthenticatedDns(altered, 'alice.example'), + throwsA(isA()), + ); + } + }, + ); + test('follows authenticated CNAMEs and ignores unrelated TXT answers', () { + final records = decodeAuthenticatedDns( + reply( + answers: [ + { + 'name': 'alice.example.', + 'type': 5, + 'TTL': 60, + 'data': 'pay.example.', + }, + txt('pay.example.', 'oa1:xmr recipient_address=valid;'), + txt('attacker.example.', 'oa1:xmr recipient_address=second;'), + ], + ), + 'alice.example', + ); + expect(select(records).address, 'valid'); + }); + test('follows CNAMEs to underscore names', () { + final records = decodeAuthenticatedDns( + reply( + answers: [ + { + 'name': 'alice.example.', + 'type': 5, + 'TTL': 60, + 'data': '_oa.pay.example.', + }, + txt('_oa.pay.example.', 'oa1:xmr recipient_address=valid;'), + ], + ), + 'alice.example', + ); + expect(select(records).address, 'valid'); + }); + test('names the alias when no record exists or the lookup fails', () { + for (final (status, message) in [ + (3, 'No OpenAlias record exists for alice.example.'), + (2, 'The DNS lookup for alice.example failed. No address was accepted.'), + ]) { + expect( + () => decodeAuthenticatedDns({ + ...reply(), + 'Status': status, + }, 'alice.example'), + throwsA( + isA().having( + (e) => e.message, + 'message', + message, + ), + ), + ); + } + }); + test('reports malformed answer names as invalid responses', () { + expect( + () => decodeAuthenticatedDns( + reply( + answers: [ + { + 'name': 'alice.example.', + 'type': 5, + 'TTL': 60, + 'data': 'pay..example.', + }, + ], + ), + 'alice.example', + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Invalid DNS response.', + ), + ), + ); + }); + test('rejects CNAME loops and CNAME/TXT ambiguity', () { + for (final answers in [ + [ + {'name': 'alice.example.', 'type': 5, 'data': 'alice.example.'}, + ], + [ + {'name': 'alice.example.', 'type': 5, 'data': 'pay.example.'}, + txt('alice.example.', 'oa1:xmr recipient_address=valid;'), + ], + ]) { + expect( + () => decodeAuthenticatedDns(reply(answers: answers), 'alice.example'), + throwsA(isA()), + ); + } + }); +} diff --git a/test/services/openalias/doh_transport_test.dart b/test/services/openalias/doh_transport_test.dart new file mode 100644 index 0000000000..32d23f0dbf --- /dev/null +++ b/test/services/openalias/doh_transport_test.dart @@ -0,0 +1,358 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:async/async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/services/openalias/dns_proxy_connection.dart'; +import 'package:stackwallet/services/openalias/doh_open_alias.dart'; +import 'package:stackwallet/services/openalias/open_alias.dart'; + +void main() { + test( + 'immediate cancellation completes without leaving a connection', + () async { + final proxy = await _StalledTlsProxy.start(); + addTearDown(proxy.close); + final task = DnsProxyConnection( + InternetAddress.loopbackIPv4, + proxy.server.port, + ); + final failed = expectLater(task.socket, throwsA(isA())); + task.cancel(); + await failed.timeout(const Duration(seconds: 2)); + final peer = await proxy.nextPeer(); + await peer.disconnected.future.timeout(const Duration(seconds: 2)); + }, + ); + + test( + 'TLS transport carries HTTP with backpressure and cancels after success', + () async { + final body = List.generate(256 * 1024, (index) => index % 256); + final served = Completer(); + final disconnected = Completer(); + final proxy = await _TlsSocksProxy.start((socket) async { + final reader = ChunkedStreamReader(socket); + final header = []; + while (!ascii.decode(header).endsWith('\r\n\r\n')) { + header.addAll(await reader.readBytes(1)); + } + expect(ascii.decode(header), contains('POST /resolve HTTP/1.1')); + // Let the sender fill the TLS buffers before consuming its body. + await Future.delayed(const Duration(milliseconds: 20)); + expect(await reader.readBytes(body.length), body); + socket.add( + ascii.encode( + 'HTTP/1.1 200 OK\r\nContent-Length: ${body.length}\r\n\r\n', + ), + ); + socket.add(body); + await socket.flush(); + served.complete(); + expect(await reader.readBytes(1), isEmpty); + disconnected.complete(); + }); + addTearDown(proxy.close); + final context = SecurityContext(withTrustedRoots: false) + ..setTrustedCertificates(_certificate); + final task = DnsProxyConnection( + InternetAddress.loopbackIPv4, + proxy.server.port, + context: context, + ); + addTearDown(task.cancel); + final client = HttpClient() + ..connectionFactory = (uri, _, _) async => + ConnectionTask.fromSocket(task.socket, task.cancel); + addTearDown(() => client.close(force: true)); + final request = await client.postUrl(Uri.https('dns.google', '/resolve')); + request.contentLength = body.length; + request.add(body); + final response = await request.close(); + expect(response.statusCode, 200); + expect(response.certificate, isNotNull); + final received = []; + await for (final chunk in response) { + received.addAll(chunk); + } + expect(received, body); + await served.future.timeout(const Duration(seconds: 2)); + task.cancel(); + task.cancel(); + await disconnected.future.timeout(const Duration(seconds: 2)); + expect(proxy.errors, isEmpty); + }, + ); + + test('TLS still rejects an untrusted certificate', () async { + final proxy = await _TlsSocksProxy.start((socket) async { + await socket.drain(); + }); + addTearDown(proxy.close); + final task = DnsProxyConnection( + InternetAddress.loopbackIPv4, + proxy.server.port, + ); + addTearDown(task.cancel); + await expectLater(task.socket, throwsA(isA())); + }); + + test( + 'cancelling stalled TLS closes the peer and completes the task', + () async { + final proxy = await _StalledTlsProxy.start(); + addTearDown(proxy.close); + for (var attempt = 0; attempt < 3; attempt++) { + final task = DnsProxyConnection( + InternetAddress.loopbackIPv4, + proxy.server.port, + ); + final failed = expectLater( + task.socket, + throwsA(isA()), + ); + addTearDown(task.cancel); + final peer = await proxy.nextPeer(); + await peer.clientHello.future.timeout(const Duration(seconds: 2)); + task.cancel(); + task.cancel(); + await peer.disconnected.future.timeout(const Duration(seconds: 2)); + await failed.timeout(const Duration(seconds: 2)); + } + }, + ); + + test('lookup timeout closes a stalled TLS connection', () async { + final proxy = await _StalledTlsProxy.start(); + addTearDown(proxy.close); + final failed = expectLater( + const DohOpenAlias(lookupTimeout: Duration(seconds: 1)).lookup( + 'alice.example', + proxyInfo: ( + host: InternetAddress.loopbackIPv4, + port: proxy.server.port, + ), + ), + throwsA(isA()), + ); + final peer = await proxy.nextPeer(); + await peer.clientHello.future.timeout(const Duration(seconds: 2)); + await failed.timeout(const Duration(seconds: 4)); + await peer.disconnected.future.timeout(const Duration(seconds: 2)); + }); + + test('cancelling a stalled SOCKS handshake closes the socket', () async { + final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final greeted = Completer(); + final disconnected = Completer(); + Socket? peer; + final sub = server.listen((socket) { + peer = socket; + socket.listen((_) { + if (!greeted.isCompleted) greeted.complete(); + }, onDone: () => disconnected.complete()); + }); + final task = DnsProxyConnection(InternetAddress.loopbackIPv4, server.port); + final failed = expectLater(task.socket, throwsA(isA())); + try { + await greeted.future.timeout(const Duration(seconds: 2)); + task.cancel(); + await failed; + await disconnected.future.timeout(const Duration(seconds: 2)); + } finally { + task.cancel(); + peer?.destroy(); + await sub.cancel(); + await server.close(); + } + }); + + test( + 'SOCKS connects to a numeric endpoint without resolving dns.google locally', + () async { + final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final requested = Completer>(); + final sockets = []; + final sub = server.listen((socket) { + sockets.add(socket); + final bytes = []; + var greeted = false; + socket.listen((data) { + bytes.addAll(data); + if (!greeted && bytes.length >= 3) { + expect(bytes.sublist(0, 3), [5, 1, 0]); + bytes.removeRange(0, 3); + greeted = true; + socket.add([5, 0]); + } + if (greeted && bytes.length >= 10 && !requested.isCompleted) { + requested.complete(bytes.sublist(0, 10)); + socket.add([5, 5, 0, 1, 0, 0, 0, 0, 0, 0]); + } + }); + }); + try { + await expectLater( + const DohOpenAlias().lookup( + 'alice.example', + proxyInfo: (host: InternetAddress.loopbackIPv4, port: server.port), + ), + throwsA(isA()), + ); + expect(await requested.future.timeout(const Duration(seconds: 2)), [ + 5, + 1, + 0, + 1, + 8, + 8, + 8, + 8, + 1, + 187, + ]); + } finally { + for (final socket in sockets) { + socket.destroy(); + } + await sub.cancel(); + await server.close(); + } + }, + ); +} + +class _StalledTlsProxy { + final ServerSocket server; + final _accepted = StreamController<_StalledTlsPeer>(); + final _peers = <_StalledTlsPeer>[]; + late final StreamIterator<_StalledTlsPeer> _iterator; + late final StreamSubscription _subscription; + + _StalledTlsProxy(this.server) { + _iterator = StreamIterator(_accepted.stream); + _subscription = server.listen((socket) { + final peer = _StalledTlsPeer(socket); + _peers.add(peer); + _accepted.add(peer); + }); + } + + static Future<_StalledTlsProxy> start() async => _StalledTlsProxy( + await ServerSocket.bind(InternetAddress.loopbackIPv4, 0), + ); + + Future<_StalledTlsPeer> nextPeer() async { + await _iterator.moveNext().timeout(const Duration(seconds: 2)); + return _iterator.current; + } + + Future close() async { + for (final peer in _peers) { + peer.socket.destroy(); + } + await _iterator.cancel(); + await _accepted.close(); + await _subscription.cancel(); + await server.close(); + } +} + +class _StalledTlsPeer { + final Socket socket; + final clientHello = Completer(); + final disconnected = Completer(); + + _StalledTlsPeer(this.socket) { + final bytes = []; + var stage = 0; + socket.listen((data) { + bytes.addAll(data); + if (stage == 0 && bytes.length >= 3) { + expect(bytes.sublist(0, 3), [5, 1, 0]); + bytes.removeRange(0, 3); + socket.add([5, 0]); + stage = 1; + } + if (stage == 1 && bytes.length >= 10) { + expect(bytes.sublist(0, 10), [5, 1, 0, 1, 8, 8, 8, 8, 1, 187]); + bytes.removeRange(0, 10); + socket.add([5, 0, 0, 1, 127, 0, 0, 1, 0, 0]); + stage = 2; + } + if (stage == 2 && bytes.length >= 6) { + // TLS handshake record followed by a ClientHello message. + expect(bytes[0], 22); + expect(bytes[5], 1); + clientHello.complete(); + stage = 3; + bytes.clear(); + } + }, onDone: disconnected.complete); + } +} + +// Test-only identity for dns.google; never added to the default trust store. +const _certificate = + 'test/services/openalias/fixtures/dns_google_test_cert.pem'; +const _privateKey = 'test/services/openalias/fixtures/dns_google_test_key.pem'; + +class _TlsSocksProxy { + final ServerSocket server; + final errors = []; + final _sockets = []; + final _tasks = >[]; + late final StreamSubscription _subscription; + + _TlsSocksProxy(this.server, Future Function(SecureSocket) handle) { + final context = SecurityContext() + ..useCertificateChain(_certificate) + ..usePrivateKey(_privateKey); + _subscription = server.listen((socket) { + _sockets.add(socket); + _tasks.add( + _serve(socket, context, handle).catchError((Object error) { + errors.add(error); + }), + ); + }); + } + + Future _serve( + Socket socket, + SecurityContext context, + Future Function(SecureSocket) handle, + ) async { + final reader = ChunkedStreamReader(socket); + expect(await reader.readBytes(3), [5, 1, 0]); + socket.add([5]); + await socket.flush(); + socket.add([0]); + expect(await reader.readBytes(10), [5, 1, 0, 1, 8, 8, 8, 8, 1, 187]); + // A fragmented domain-form reply also exercises exact SOCKS reads. + for (final byte in [5, 0, 0, 3, 3, 102, 111, 111, 0, 0]) { + socket.add([byte]); + await socket.flush(); + } + final secure = await SecureSocket.secureServer(socket, context); + _sockets.add(secure); + await handle(secure); + } + + static Future<_TlsSocksProxy> start( + Future Function(SecureSocket) handle, + ) async => _TlsSocksProxy( + await ServerSocket.bind(InternetAddress.loopbackIPv4, 0), + handle, + ); + + Future close() async { + for (final socket in _sockets) { + socket.destroy(); + } + await _subscription.cancel(); + await server.close(); + await Future.wait(_tasks).timeout(const Duration(seconds: 2)); + } +} diff --git a/test/services/openalias/fixtures/dns_google_test_cert.pem b/test/services/openalias/fixtures/dns_google_test_cert.pem new file mode 100644 index 0000000000..d000e0196b --- /dev/null +++ b/test/services/openalias/fixtures/dns_google_test_cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDJDCCAgygAwIBAgIUPbeZfpJ66kK5XfnheYjOKq4b0HkwDQYJKoZIhvcNAQEL +BQAwFTETMBEGA1UEAwwKZG5zLmdvb2dsZTAgFw0yNjEwMDExNTM5MDZaGA8yMTI2 +MDkwNzE1MzkwNlowFTETMBEGA1UEAwwKZG5zLmdvb2dsZTCCASIwDQYJKoZIhvcN +AQEBBQADggEPADCCAQoCggEBAMnviDZT0CPi3bWFd1WxNpZS/4tDxzLDyiddd0jg +C+n6C90Cjb2z5QckDRijd+4a9wC5EGVMQDyXoilxhKU/50HrpG3+PGmhLweYqbEI +MCXeVPHK8wWY6p1UmL4WzeEf90bFL7t17zA6uA/P5KoSSXbWkQ/nrJ/FBzlFYQqy +1Qq7RdV6t+XR43OblaJbHBG6+LKAGUUpwOVHBtNT5YN2VQiJXAy7hIaO3ZzU4WfI +5fBeUYDAsHtoRocDqKmwGNt0lzmrKeNKGO4drLMK2IYiLkYQVzCARr9e2yYNZzVI +ADDGXi2+mb1yhgwBPQsoi7tmw0tF3Ir82CnpCswh211l5qUCAwEAAaNqMGgwHQYD +VR0OBBYEFI3/+X5BRQKsDWOtawAgt5MeZkgfMB8GA1UdIwQYMBaAFI3/+X5BRQKs +DWOtawAgt5MeZkgfMA8GA1UdEwEB/wQFMAMBAf8wFQYDVR0RBA4wDIIKZG5zLmdv +b2dsZTANBgkqhkiG9w0BAQsFAAOCAQEAUJrI5uryhf0CbpTLNpg0kacDYv+l9jZ2 +DbYq6hKk980bUiIWT9oQR7U5Y1QAJXKrAzN4CsxGrOXcqYwyGRaJHgzP0gYe/uVK +Q34LWJtcXopOb72s9jLrOKPMJgTT558UnmiAKZbHpCyxIpfJVSv50/u0FYNM7txs +4DDaAxZSpVNlJ6WTWVmvTjWUp4yBLrveAHD6+P6Wyf/H/Nx2eEcDk8ylArOAQcDG +Pqd/bO6HgZm9vFsyScIxqpUPA6mV94qixSwnLnzHtnAXWEW/PgZ5+5I3J4fbtZBs +8CgoiE3Gyk4jMlfQ1W/DXbZRa+z5rj9KqiiDa6oV54svCHk+39HYsA== +-----END CERTIFICATE----- diff --git a/test/services/openalias/fixtures/dns_google_test_key.pem b/test/services/openalias/fixtures/dns_google_test_key.pem new file mode 100644 index 0000000000..5204031813 --- /dev/null +++ b/test/services/openalias/fixtures/dns_google_test_key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDJ74g2U9Aj4t21 +hXdVsTaWUv+LQ8cyw8onXXdI4Avp+gvdAo29s+UHJA0Yo3fuGvcAuRBlTEA8l6Ip +cYSlP+dB66Rt/jxpoS8HmKmxCDAl3lTxyvMFmOqdVJi+Fs3hH/dGxS+7de8wOrgP +z+SqEkl21pEP56yfxQc5RWEKstUKu0XVerfl0eNzm5WiWxwRuviygBlFKcDlRwbT +U+WDdlUIiVwMu4SGjt2c1OFnyOXwXlGAwLB7aEaHA6ipsBjbdJc5qynjShjuHayz +CtiGIi5GEFcwgEa/XtsmDWc1SAAwxl4tvpm9coYMAT0LKIu7ZsNLRdyK/Ngp6QrM +IdtdZealAgMBAAECggEABeDU5n30mGLVgN/4fBRbSi9y1Gc98HaeFxzDqOmqtx4q +A748CYpnz5gnUHELv/DlgU4h0c0RLqJcXFf7XEMatILLR89ni0yfnZk+UVKzchrB +fsNhZSeRJtDMXHY1+ohvbo41aD/T3WPMMVukJKZBILY4xU2Bayo1Xl+sZj8T8Aq7 +yYycZ/MRJ9CaZDsjgiSuRvzt5aa2RsaHp2CIvhUvswsvUXQkAVHRBegk9FQs7j++ +mCGsOWtT481a7/bOVxd29M9zQXspX3tXKUjrKPa48ByA+i13UvCkFBkwFkEWlcWG +6pQbjzWQqTatQCHvyBUB4uZehuqbxuGbIOdySYpWyQKBgQDvlR9QssF/KkY96EXK +lIc1w8H7O1mOt8ovzk1rFbUIQKc07FeqbRwdOuvnufAD56oADwwwDUL5kcfFCtOt +gMfH5SMGg3WdwHt+EbXbzqJJG/djR7OUS8/KHZ7cu9ZLmksLZ+yF/VYYC8rPig1b +a8pAy2APlLPgiOZ9xErT1LfISQKBgQDXxf1ozTfDJAcMiTNg0OCkR/wHFMTEHEUx +voRdj/ML3osdrl44tVVRaU0zLtHFBpE/partLbG41DDAuNPbhEzKdmTFN1DcYRmg +CXxhEqFJum0T46QSLLYlkLuKcthqXcWJ1+ZBEF+Ktqo89AxFi0Dc0lKjW8f8Oe9C +hUN2YE5DfQKBgQCs6t9YOjxoENXztqIwrXTNPEXUkyh3VnVqfabo5BjuJOPJ0c8Q +JYIGItHDRTNTTjAnrLSqNdgiErKxp7fR4UHQJ7RY7ibEvfkjq5qrU/9TbwjlsgmN +vg3yHeDcBgKMPIdvawmOR6OM5W3yzvwHAMxQabkiZbxSEdzx5uvu6fffaQKBgQCI +1O2WgE1UWox9zqPeSZerllqkwu7kzmiy2vZlvtuKM15VPDZUD+pC7lwekRpFr5x8 +mDqEne2OIEGqdYskMgngeqP8zM9icdEmQ920UXQmpSp26m0peWEpAVdsvNew3DZg +66EAsblk5eGfNcRydZR0dIwjPBjeQn8P2CKn9vqqoQKBgGdW1TpfMlsxf1e5LF9z +L/IYeBQrMabLF1NRED4sxQ/6JhtDOJrsTtLwYMiJlUvSN3PrJMaxPJQbmUQ14UEH +SdWT8jQTz3Ci5k5Duvoo5MX9HEJCDtNPVWPTL7V/Jyt/59XSXqO08kecPinH9bGk +P7aRRl5uF+WulHlJG2FfEoZS +-----END PRIVATE KEY----- diff --git a/test/services/openalias/open_alias_live_test.dart b/test/services/openalias/open_alias_live_test.dart new file mode 100644 index 0000000000..54bc3f2fe5 --- /dev/null +++ b/test/services/openalias/open_alias_live_test.dart @@ -0,0 +1,35 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/services/openalias/open_alias.dart'; +import 'package:stackwallet/services/openalias/open_alias_service.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; + +void main() { + final skip = Platform.environment['OPENALIAS_LIVE_TESTS'] != '1'; + final service = OpenAliasService( + externalCalls: () => true, + useTor: () => false, + ); + final validate = Monero(CryptoCurrencyNetwork.main).validateAddress; + test( + 'live signed alias resolves to a valid Monero mainnet address', + () async { + final result = await service.resolve( + 'donate.getmonero.org', + validateAddress: validate, + ); + expect(result.domain, 'donate.getmonero.org'); + expect(validate(result.address), isTrue); + }, + skip: skip, + ); + for (final domain in ['openalias.org', 'dnssec-failed.org']) { + test('live unsigned or bogus DNS is rejected: $domain', () async { + await expectLater( + service.resolve(domain, validateAddress: validate), + throwsA(isA()), + ); + }, skip: skip); + } +} diff --git a/test/services/openalias/open_alias_service_test.dart b/test/services/openalias/open_alias_service_test.dart new file mode 100644 index 0000000000..03232b0313 --- /dev/null +++ b/test/services/openalias/open_alias_service_test.dart @@ -0,0 +1,81 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/services/openalias/open_alias.dart'; +import 'package:stackwallet/services/openalias/open_alias_service.dart'; + +void main() { + test('privacy restrictions prevent any network lookup', () async { + var calls = 0; + for (final policy in [(false, false, true), (true, true, false)]) { + final service = OpenAliasService( + externalCalls: () => policy.$1, + useTor: () => policy.$2, + supportsTor: () => policy.$3, + lookup: (_, _) async { + calls++; + return []; + }, + ); + await expectLater( + service.resolve('alice.example', validateAddress: (_) => true), + throwsA(isA()), + ); + } + expect(calls, 0); + }); + test('Incognito mode points to the setting that allows lookups', () async { + final service = OpenAliasService( + externalCalls: () => false, + lookup: (_, _) async => [], + ); + await expectLater( + service.resolve('alice.example', validateAddress: (_) => true), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Experience to Easy Crypto in Advanced settings'), + ), + ), + ); + }); + test( + 'Tor policy is passed to the lookup and a changed policy rejects results', + () async { + var tor = true; + final pending = Completer>(); + final service = OpenAliasService( + externalCalls: () => true, + useTor: () => tor, + supportsTor: () => true, + lookup: (domain, useTor) { + expect(domain, 'alice.example'); + expect(useTor, isTrue); + return pending.future; + }, + ); + final future = service.resolve( + 'alice@example', + validateAddress: (_) => true, + ); + tor = false; + pending.complete(['oa1:xmr recipient_address=valid;']); + await expectLater(future, throwsA(isA())); + }, + ); + test('returns only a validated literal recipient', () async { + final service = OpenAliasService( + externalCalls: () => true, + useTor: () => false, + supportsTor: () => true, + lookup: (_, _) async => ['oa1:xmr recipient_address=valid;'], + ); + final result = await service.resolve( + 'alice.example', + validateAddress: (s) => s == 'valid', + ); + expect(result.address, 'valid'); + expect(result.domain, 'alice.example'); + }); +} diff --git a/test/services/openalias/open_alias_test.dart b/test/services/openalias/open_alias_test.dart new file mode 100644 index 0000000000..4d0a22d030 --- /dev/null +++ b/test/services/openalias/open_alias_test.dart @@ -0,0 +1,97 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/services/openalias/open_alias.dart'; + +OpenAliasRecipient select(List records) => selectOpenAliasRecipient( + domain: 'alice.example', + records: records, + validateAddress: (s) => s == 'valid' || s == 'second', +); + +void main() { + test('normalizes email aliases, case and root dot', () { + expect(normalizeOpenAlias(' Alice@Example. '), 'alice.example'); + expect( + normalizeOpenAlias('xn--bcher-kva.example'), + 'xn--bcher-kva.example', + ); + expect(normalizeOpenAlias('Dan_M@_Pay.Example'), 'dan_m._pay.example'); + }); + test('names the alias when it has no Monero record', () { + expect( + () => select(['oa1:btc recipient_address=valid;']), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'No Monero OpenAlias record was found for alice.example.', + ), + ), + ); + }); + test('displays aliases as entered, without case or root dot', () { + expect(displayOpenAlias(' Dan@CypherStack.com. '), 'dan@cypherstack.com'); + }); + for (final name in [ + 'example', + 'https://example.org', + 'a@@b.org', + 'a..org', + 'a.org/path', + '-a.org', + 'ü.example', + 'K.example', + '127.0.0.1', + 'a.onion', + 'a.org\u0000', + ]) { + test('rejects invalid alias $name', () { + expect( + () => normalizeOpenAlias(name), + throwsA(isA()), + ); + }); + } + test( + 'parses semicolon fields and quoted values without requiring spaces', + () { + expect( + select([ + 'oa1:xmr recipient_name="Alice; Example";recipient_address="valid";', + ]).address, + 'valid', + ); + }, + ); + test('ignores other coins and nonmatching currency prefixes', () { + expect( + select([ + 'oa1:btc recipient_address=bad;', + 'oa1:xmrfoo recipient_address=bad;', + 'oa1:xmr recipient_address=valid;', + ]).address, + 'valid', + ); + }); + for (final records in [ + [], + ['oa1:xmr recipient_address=wrong-network;'], + ['oa1:xmr recipient_address=valid;recipient_address=second;'], + ['oa1:xmr recipient_address=valid;', 'oa1:xmr recipient_address=second;'], + ['oa1:xmr recipient_address=valid;tx_payment_id=0123456789abcdef;'], + ['oa1:xmr recipient_address="valid;'], + ]) { + test( + 'rejects missing, invalid or ambiguous payment instructions $records', + () { + expect(() => select(records), throwsA(isA())); + }, + ); + } + test('does not apply suggested amounts or names to recipient', () { + final result = select([ + 'oa1:xmr recipient_address=valid;tx_amount=999;recipient_name=Someone;', + ]); + expect(result.domain, 'alice.example'); + expect(result.address, 'valid'); + }); +} diff --git a/test/services/openalias/open_alias_transaction_test.dart b/test/services/openalias/open_alias_transaction_test.dart new file mode 100644 index 0000000000..700df3e64c --- /dev/null +++ b/test/services/openalias/open_alias_transaction_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/services/openalias/open_alias.dart'; +import 'package:stackwallet/wallets/models/tx_data.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/address.dart'; +import 'package:stackwallet/utilities/amount/amount.dart'; + +void main() { + test('fee updates preserve the alias; recipient changes invalidate it', () { + const alias = OpenAliasRecipient( + domain: 'alice.example', + address: 'accepted', + ); + final recipient = TxRecipient( + address: 'accepted', + amount: Amount(rawValue: BigInt.one, fractionDigits: 12), + isChange: false, + addressType: AddressType.cryptonote, + ); + final prepared = TxData( + recipients: [recipient], + openAliasRecipient: alias, + ).copyWith(fee: Amount(rawValue: BigInt.two, fractionDigits: 12)); + expect(prepared.recipients!.single.address, 'accepted'); + expect(prepared.openAliasRecipient, same(alias)); + expect( + prepared + .copyWith(recipients: [recipient.copyWith(address: 'different')]) + .openAliasRecipient, + isNull, + ); + expect( + prepared.copyWith(recipients: [recipient, recipient]).openAliasRecipient, + isNull, + ); + }); +} diff --git a/test/services/openalias/send_preview_eligibility_test.dart b/test/services/openalias/send_preview_eligibility_test.dart new file mode 100644 index 0000000000..5fce56bd78 --- /dev/null +++ b/test/services/openalias/send_preview_eligibility_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/providers/ui/preview_tx_button_state_provider.dart'; +import 'package:stackwallet/utilities/amount/amount.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; + +void main() { + test( + 'alias eligibility requires an amount and respects unsupported data', + () { + final container = ProviderContainer(); + addTearDown(container.dispose); + final coin = Monero(CryptoCurrencyNetwork.main); + final enabled = pPreviewTxButtonEnabledForDestination(( + coin: coin, + isAlias: true, + )); + final subscription = container.listen(enabled, (_, _) {}); + addTearDown(subscription.close); + expect(container.read(enabled), isFalse); + container.read(pSendAmount.notifier).state = Amount( + rawValue: BigInt.one, + fractionDigits: 12, + ); + expect(container.read(enabled), isTrue); + expect(container.read(pValidSendToAddress), isFalse); + container.read(pOpReturnData.notifier).state = '00'; + expect(container.read(enabled), isFalse); + container.read(pOpReturnData.notifier).state = null; + container.read(pSendAmount.notifier).state = Amount.zero; + expect(container.read(enabled), isFalse); + }, + ); + + test('alias state is local and never enables another wallet or coin', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + final monero = Monero(CryptoCurrencyNetwork.main); + final bitcoin = Bitcoin(CryptoCurrencyNetwork.main); + final alias = pPreviewTxButtonEnabledForDestination(( + coin: monero, + isAlias: true, + )); + final literal = pPreviewTxButtonEnabledForDestination(( + coin: monero, + isAlias: false, + )); + final other = pPreviewTxButtonEnabledForDestination(( + coin: bitcoin, + isAlias: true, + )); + final subscriptions = [ + alias, + literal, + other, + ].map((p) => container.listen(p, (_, _) {})).toList(); + addTearDown(() { + for (final s in subscriptions) { + s.close(); + } + }); + container.read(pSendAmount.notifier).state = Amount( + rawValue: BigInt.one, + fractionDigits: 12, + ); + expect(container.read(alias), isTrue); + expect(container.read(literal), isFalse); + expect(container.read(other), isFalse); + container.read(pValidSendToAddress.notifier).state = true; + expect(container.read(literal), isTrue); + expect(container.read(other), isTrue); + }); +} diff --git a/test/services/openalias/send_preview_test.dart b/test/services/openalias/send_preview_test.dart new file mode 100644 index 0000000000..a2fd7f8245 --- /dev/null +++ b/test/services/openalias/send_preview_test.dart @@ -0,0 +1,173 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/services/openalias/open_alias.dart'; +import 'package:stackwallet/services/openalias/send_preview.dart'; + +void main() { + late SendPreview preview; + late List prepared; + late List errors; + var calls = 0; + setUp(() { + preview = SendPreview(); + prepared = []; + errors = []; + calls = 0; + }); + tearDown(() => preview.dispose()); + + Future run({ + String source = 'Alice@Example', + Future? pending, + bool Function()? current, + Future? preparing, + }) => preview.run( + walletId: 'wallet', + source: source, + isCurrent: current ?? () => true, + work: (attempt) async { + final result = await attempt.resolve( + supportsOpenAlias: true, + validateAddress: (address) => address == 'literal', + lookup: (domain) { + calls++; + expect(domain, 'alice.example'); + return pending ?? + Future.value( + const OpenAliasRecipient( + domain: 'alice.example', + address: 'literal', + ), + ); + }, + ); + attempt.checkCurrent(); + prepared.add(result); + if (preparing != null) await preparing; + attempt.checkCurrent(); + }, + onError: (e, _) => errors.add(e), + ); + + test('literal address bypasses lookup', () async { + await run(source: 'literal'); + expect(calls, 0); + expect(prepared.single.destination, 'literal'); + expect(prepared.single.alias, isNull); + }); + test( + 'each preview resolves anew and prepares only a validated address', + () async { + await run(); + await run(); + expect(calls, 2); + expect(prepared.map((e) => e.destination), ['literal', 'literal']); + expect(prepared.last.alias!.domain, 'alice.example'); + expect(prepared.last.alias!.displayAlias, 'alice@example'); + expect(errors, isEmpty); + }, + ); + test('lookup failures do not prepare', () async { + await run(pending: Future.error(const OpenAliasException('DNSSEC failed'))); + expect(prepared, isEmpty); + expect(errors.single.toString(), 'DNSSEC failed'); + expect(preview.busy, isFalse); + }); + test('invalid resolver results do not prepare', () async { + await run( + pending: Future.value( + const OpenAliasRecipient( + domain: 'alice.example', + address: 'wrong-wallet', + ), + ), + ); + expect(prepared, isEmpty); + expect(errors.single, isA()); + }); + test('duplicate previews are suppressed while lookup is pending', () async { + final pending = Completer(); + final first = run(pending: pending.future); + await run(); + expect(calls, 1); + pending.complete( + const OpenAliasRecipient(domain: 'alice.example', address: 'literal'), + ); + await first; + expect(prepared, hasLength(1)); + }); + for (final reason in ['edit away and back', 'cancel', 'wallet switch']) { + test('$reason invalidates old success and permits a new attempt', () async { + final pending = Completer(); + final first = run(pending: pending.future); + preview.invalidate(); + await run(); + pending.complete( + const OpenAliasRecipient(domain: 'alice.example', address: 'literal'), + ); + await first; + expect(prepared, hasLength(1)); + expect(errors, isEmpty); + }); + } + test('a stale failure cannot show an error over the new draft', () async { + final pending = Completer(); + final first = run(pending: pending.future); + preview.invalidate(); + pending.completeError(const OpenAliasException('old failure')); + await first; + expect(prepared, isEmpty); + expect(errors, isEmpty); + }); + test( + 'external wallet/source identity is checked before preparation', + () async { + var current = true; + final pending = Completer(); + final first = run(pending: pending.future, current: () => current); + current = false; + pending.complete( + const OpenAliasRecipient(domain: 'alice.example', address: 'literal'), + ); + await first; + expect(prepared, isEmpty); + }, + ); + test('cancel during preparation prevents navigation', () async { + final pending = Completer(); + var navigated = false; + final first = preview.run( + walletId: 'wallet', + source: 'literal', + isCurrent: () => true, + work: (attempt) async { + await pending.future; + attempt.checkCurrent(); + navigated = true; + }, + onError: (e, _) => errors.add(e), + ); + preview.invalidate(); + pending.complete(); + await first; + expect(navigated, isFalse); + expect(errors, isEmpty); + }); + test( + 'disposal invalidates lookup without notifying a disposed screen', + () async { + final pending = Completer(); + final first = run(pending: pending.future); + final disposed = preview; + disposed.dispose(); + preview = SendPreview(); + pending.complete( + const OpenAliasRecipient(domain: 'alice.example', address: 'literal'), + ); + await first; + expect(prepared, isEmpty); + expect(errors, isEmpty); + }, + ); +} diff --git a/test/services/openalias/send_recipient_test.dart b/test/services/openalias/send_recipient_test.dart new file mode 100644 index 0000000000..ec9b0dd4df --- /dev/null +++ b/test/services/openalias/send_recipient_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/services/openalias/send_recipient.dart'; + +void main() { + SendRecipient classify(String input, {bool supportsOpenAlias = true}) => + SendRecipient.classify( + input, + supportsOpenAlias: supportsOpenAlias, + validateAddress: (value) => value == 'literal-address', + ); + + test('literal addresses take precedence and preserve case', () { + final result = classify(' literal-address '); + expect(result.kind, SendRecipientKind.literal); + expect(result.destination, 'literal-address'); + expect(result.isAlias, isFalse); + final domainAddress = SendRecipient.classify( + 'Native.Address', + supportsOpenAlias: true, + validateAddress: (_) => true, + ); + expect(domainAddress.kind, SendRecipientKind.literal); + expect(domainAddress.destination, 'Native.Address'); + }); + + test('domain and email aliases use OpenAlias normalization', () { + for (final input in [' Alice.Example. ', 'Alice@Example']) { + final result = classify(input); + expect(result.kind, SendRecipientKind.openAlias); + expect(result.destination, 'alice.example'); + } + }); + + test('invalid forms remain invalid without a lookup', () { + for (final input in [ + '', + 'alice', + 'a@@b.org', + 'https://alice.example', + 'a..org', + '127.0.0.1', + 'a.onion', + 'ü.example', + 'monero:literal-address', + ]) { + expect(classify(input).kind, SendRecipientKind.invalid, reason: input); + } + }); + + test('coins without the capability accept only their literal addresses', () { + expect( + classify('alice.example', supportsOpenAlias: false).kind, + SendRecipientKind.invalid, + ); + expect( + classify('literal-address', supportsOpenAlias: false).kind, + SendRecipientKind.literal, + ); + }); +} diff --git a/test/utilities/util_layout_test.dart b/test/utilities/util_layout_test.dart new file mode 100644 index 0000000000..60d1920236 --- /dev/null +++ b/test/utilities/util_layout_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/utilities/util.dart'; + +void main() { + test( + 'explicit layout overrides host, viewport width, and iPad detection', + () { + final previousLayout = Util.debugIsDesktopOverride; + final previousWidth = Util.screenWidth; + final previousIpad = Util.isIpad; + addTearDown(() { + Util.debugIsDesktopOverride = previousLayout; + Util.screenWidth = previousWidth; + Util.isIpad = previousIpad; + }); + + for (final width in [390.0, 1200.0]) { + for (final ipad in [false, true]) { + Util.screenWidth = width; + Util.isIpad = ipad; + Util.debugIsDesktopOverride = null; + final detectedLayout = Util.isDesktop; + + Util.debugIsDesktopOverride = false; + expect(Util.isDesktop, isFalse); + Util.debugIsDesktopOverride = true; + expect(Util.isDesktop, isTrue); + + Util.debugIsDesktopOverride = null; + expect(Util.isDesktop, detectedLayout); + } + } + }, + ); +}