diff --git a/.gitignore b/.gitignore index 1b5b2b46cc..4ac7bfa2a2 100644 --- a/.gitignore +++ b/.gitignore @@ -74,6 +74,7 @@ secp256k1.dll ## other generated project files pubspec.yaml +pubspec_overrides.yaml /android/app/build.gradle /android/app/src/debug/AndroidManifest.xml diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index 0bd3081323..c07d33d5dc 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -789,6 +789,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( @@ -1146,6 +1165,41 @@ class _ConfirmTransactionViewState ], ), ), + 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, diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 34dbc6d081..99ad553bdf 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -29,6 +29,9 @@ 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/open_alias.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 +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_white_container.dart'; import '../../widgets/stack_dialog.dart'; import '../../widgets/stack_text_field.dart'; @@ -124,6 +128,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 +183,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; @@ -261,10 +303,11 @@ class _SendViewState extends ConsumerState { Future _pasteAddress() async { final ClipboardData? data = await clipboard.getData(Clipboard.kTextPlain); if (data?.text != null && data!.text!.isNotEmpty) { - String content = data.text!.trim(); - if (content.contains("\n")) { - content = content.substring(0, content.indexOf("\n")).trim(); - } + String content = prepareSendRecipientInput( + data.text!, + supportsOpenAlias: coin is Monero, + validateAddress: coin.validateAddress, + ); try { final paymentData = AddressUtils.parsePaymentUri( @@ -332,7 +375,7 @@ class _SendViewState extends ConsumerState { // .state = true, // ); - Logging.instance.d("qrResult content: ${qrResult.rawContent}"); + Logging.instance.d("QR scan completed"); if (qrResult.rawContent == null) return; final paymentData = AddressUtils.parsePaymentUri( @@ -345,7 +388,11 @@ class _SendViewState extends ConsumerState { _applyUri(paymentData); } else { _setOpReturnData(null); - _address = qrResult.rawContent!.split("\n").first.trim(); + _address = prepareSendRecipientInput( + qrResult.rawContent!, + supportsOpenAlias: coin is Monero, + validateAddress: coin.validateAddress, + ); sendToController.text = _address ?? ""; _setValidAddressProviders(_address); @@ -852,9 +899,44 @@ 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', + // Alias failures may contain a recipient's domain. Keep it out of logs. + error: error is OpenAliasException + ? 'OpenAlias lookup failed' + : 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)!; @@ -927,6 +1009,7 @@ class _SendViewState extends ConsumerState { ); } + attempt.checkCurrent(); if (shouldSendAll == null || shouldSendAll == false) { // cancel preview return; @@ -935,37 +1018,46 @@ class _SendViewState extends ConsumerState { } } - bool wasCancelled = false; + attempt.checkCurrent(); + if (!mounted) return; + final progress = TransactionPreviewDialog(); + attempt.onClose(progress.close); try { - 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) { + final operation = ref + .read(pSendOpenAliasService) + .startResolve( + input, + validateAddress: wallet.cryptoCurrency.validateAddress, + ); + attempt.onClose(operation.cancel); + return operation.result; + }, + ); + attempt.checkCurrent(); + final destination = resolved.destination; if (isPaynymSend) { txDataFuture = (wallet as PaynymInterface).preparePaymentCodeSend( @@ -999,7 +1091,7 @@ class _SendViewState extends ConsumerState { txData: TxData( sparkRecipients: [ ( - address: _address!, + address: destination, amount: amount, memo: memoController.text, isChange: false, @@ -1017,11 +1109,11 @@ class _SendViewState extends ConsumerState { txData: TxData( recipients: [ TxRecipient( - address: _address!, + address: destination, amount: amount, isChange: false, addressType: wallet.cryptoCurrency.getAddressType( - _address!, + destination, )!, ), ], @@ -1043,18 +1135,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, @@ -1072,10 +1164,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, @@ -1095,12 +1187,13 @@ class _SendViewState extends ConsumerState { txDataFuture = wallet.prepareSend( txData: TxData( xelisSendAll: coin is Xelis && _xelisSendAll, + 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, @@ -1122,12 +1215,12 @@ class _SendViewState extends ConsumerState { TxData txData = results.first as TxData; - if (wasCancelled || !mounted) { + if (!attempt.isCurrent || !mounted) { await wallet.cancelSend(txData: txData); return; } - if (!wasCancelled && mounted) { + if (mounted) { if (isPaynymSend) { txData = txData.copyWith( paynymAccountLite: widget.accountLite!, @@ -1140,8 +1233,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( @@ -1164,46 +1256,13 @@ class _SendViewState extends ConsumerState { ), ); } - } catch (e, s) { - Logging.instance.e("$e\n$s", error: e, stackTrace: s); - if (mounted && !wasCancelled) { - // 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; } @@ -1337,6 +1396,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); @@ -1409,8 +1482,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(); @@ -1438,6 +1519,24 @@ class _SendViewState extends ConsumerState { @override Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType"); + ref.listen( + prefsChangeNotifierProvider.select( + (prefs) => (prefs.externalCalls, prefs.useTor), + ), + (previous, next) { + if (previous != next) _preview.invalidate(); + }, + ); + 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), @@ -1453,7 +1552,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; @@ -1584,106 +1690,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, + ), + ], + ), ), - ), - ); - }, + ); + }, + ), ), ], ), @@ -1710,33 +1828,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) @@ -2047,7 +2146,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 68c9c8bd20..5fd25458f5 100644 --- a/lib/pages/send_view/sub_widgets/building_transaction_dialog.dart +++ b/lib/pages/send_view/sub_widgets/building_transaction_dialog.dart @@ -28,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() => @@ -116,6 +118,9 @@ class _RestoringDialogState extends ConsumerState { style: STextStyles.itemSubtitle12(context), ), onPressed: () { + if (widget.closeOnCancel) { + Navigator.of(context).pop(); + } onCancel.call(); }, ), @@ -140,6 +145,9 @@ class _RestoringDialogState extends ConsumerState { style: STextStyles.itemSubtitle12(context), ), onPressed: () { + 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 66ebcbd460..5f122621f8 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,9 @@ 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/open_alias.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 +81,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 +114,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 +167,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 +469,41 @@ 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', + // Alias failures may contain a recipient's domain. Keep it out of logs. + error: error is OpenAliasException + ? 'OpenAlias lookup failed' + : 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 +616,7 @@ class _DesktopSendState extends ConsumerState { }, ); + attempt.checkCurrent(); if (shouldSendAll == null || shouldSendAll == false) { // cancel preview return; @@ -549,40 +625,31 @@ class _DesktopSendState extends ConsumerState { } } - bool wasCancelled = false; + attempt.checkCurrent(); + if (!mounted) return; + final progress = TransactionPreviewDialog(); + attempt.onClose(progress.close); try { - 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 +657,22 @@ 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) { + final operation = ref + .read(pSendOpenAliasService) + .startResolve( + input, + validateAddress: wallet.cryptoCurrency.validateAddress, + ); + attempt.onClose(operation.cancel); + return operation.result; + }, + ); + attempt.checkCurrent(); + final destination = resolved.destination; if (isPaynymSend) { final paynymWallet = wallet as PaynymInterface; @@ -623,7 +706,7 @@ class _DesktopSendState extends ConsumerState { txData: TxData( sparkRecipients: [ ( - address: _address!, + address: destination, amount: amount, memo: memoController.text, isChange: false, @@ -643,11 +726,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 +754,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 +783,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, @@ -722,12 +805,13 @@ class _DesktopSendState extends ConsumerState { txDataFuture = wallet.prepareSend( txData: TxData( xelisSendAll: coin is Xelis && _xelisSendAll, + 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, @@ -750,12 +834,12 @@ class _DesktopSendState extends ConsumerState { txData = results.first as TxData; - if (wasCancelled || !mounted) { + if (!attempt.isCurrent || !mounted) { await wallet.cancelSend(txData: txData); return; } - if (!wasCancelled && mounted) { + if (mounted) { if (isPaynymSend) { txData = txData.copyWith( paynymAccountLite: widget.accountLite!, @@ -770,8 +854,7 @@ class _DesktopSendState extends ConsumerState { txData = txData.copyWith(noteOnChain: _onChainNote ?? ""); } } - // pop building dialog - Navigator.of(context, rootNavigator: true).pop(); + progress.close(); unawaited( showDialog( @@ -790,74 +873,13 @@ class _DesktopSendState extends ConsumerState { ), ); } - } catch (e, s) { - Logging.instance.e("Desktop send: ", error: e, stackTrace: s); - if (mounted && !wasCancelled) { - // 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; } @@ -957,7 +979,11 @@ class _DesktopSendState extends ConsumerState { _applyUri(paymentData); } else { _setOpReturnData(null); - _address = qrCodeData.split("\n").first.trim(); + _address = prepareSendRecipientInput( + qrCodeData, + supportsOpenAlias: coin is Monero, + validateAddress: coin.validateAddress, + ); sendToController.text = _address ?? ""; _setValidAddressProviders(_address); @@ -1093,10 +1119,11 @@ class _DesktopSendState extends ConsumerState { Future pasteAddress() async { final ClipboardData? data = await clipboard.getData(Clipboard.kTextPlain); if (data?.text != null && data!.text!.isNotEmpty) { - String content = data.text!.trim(); - if (content.contains("\n")) { - content = content.substring(0, content.indexOf("\n")).trim(); - } + String content = prepareSendRecipientInput( + data.text!, + supportsOpenAlias: coin is Monero, + validateAddress: coin.validateAddress, + ); try { final paymentData = AddressUtils.parsePaymentUri( @@ -1288,6 +1315,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); @@ -1347,8 +1386,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(); @@ -1369,6 +1416,24 @@ class _DesktopSendState extends ConsumerState { @override Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType"); + ref.listen( + prefsChangeNotifierProvider.select( + (prefs) => (prefs.externalCalls, prefs.useTor), + ), + (previous, next) { + if (previous != next) _preview.invalidate(); + }, + ); + 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), ); @@ -1380,7 +1445,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) @@ -1802,14 +1875,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) && @@ -2026,7 +2104,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/open_alias.dart b/lib/services/openalias/open_alias.dart new file mode 100644 index 0000000000..2942c39a4f --- /dev/null +++ b/lib/services/openalias/open_alias.dart @@ -0,0 +1,55 @@ +import 'package:doh_resolver/doh_resolver.dart'; +import 'package:openalias/openalias.dart' as oa; + +/// Stack Wallet presentation state for an OpenAlias recipient. +/// +/// The package-owned [resolved] value retains authenticated DNS evidence. This +/// wrapper deliberately does not subclass or recreate that non-forgeable type. +class OpenAliasRecipient { + final String domain; + final String address; + // The alias as the user entered it, e.g. user@example.com. + final String displayAlias; + final oa.OpenAliasRecipient? resolved; + + const OpenAliasRecipient({ + required this.domain, + required this.address, + String? displayAlias, + this.resolved, + }) : displayAlias = displayAlias ?? domain; + + String? get application => resolved?.application; + oa.OpenAliasRecord? get record => resolved?.record; + AuthenticatedTxtResult? get dns => resolved?.dns; + DnsAuthentication? get authentication => resolved?.authentication; +} + +String displayOpenAlias(String input) { + normalizeOpenAlias(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) { + try { + // Validate the original input before the package can normalize it. Unicode + // whitespace and format controls must not silently change alias identity. + if (input.codeUnits.any((unit) => unit > 0x7f)) { + throw const oa.OpenAliasException( + oa.OpenAliasError.invalidName, + 'Use punycode for international domain names.', + ); + } + return oa.normalizeOpenAlias(input); + } on oa.OpenAliasException catch (error) { + throw OpenAliasException(error.message); + } +} diff --git a/lib/services/openalias/open_alias_service.dart b/lib/services/openalias/open_alias_service.dart new file mode 100644 index 0000000000..806bf5cc79 --- /dev/null +++ b/lib/services/openalias/open_alias_service.dart @@ -0,0 +1,255 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; + +import 'package:dnssec_resolver/dnssec_resolver.dart'; +import 'package:doh_resolver/doh_resolver_io.dart'; +import 'package:openalias/openalias.dart' as oa; + +import '../../app_config.dart'; +import '../../utilities/prefs.dart'; +import '../tor_service.dart'; +import 'open_alias.dart'; + +class OpenAliasService { + static const directConnectTimeout = Duration(seconds: 5); + static const directRequestTimeout = Duration(seconds: 12); + static const directLookupTimeout = Duration(seconds: 20); + // Over Tor each connection also waits for the exit stream and TLS via Tor. + static const torConnectTimeout = Duration(seconds: 10); + static const torRequestTimeout = Duration(seconds: 20); + static const torLookupTimeout = Duration(seconds: 40); + + final bool Function() _externalCalls; + final bool Function() _useTor; + final bool Function() _supportsTor; + final List _trustedValidators; + final ({InternetAddress host, int port}) Function() _torProxy; + final Future Function(String, bool)? _lookup; + + OpenAliasService({ + bool Function()? externalCalls, + bool Function()? useTor, + bool Function()? supportsTor, + Future Function(String, bool)? lookup, + List? trustedValidators, + ({InternetAddress host, int port}) Function()? torProxy, + }) : _externalCalls = externalCalls ?? (() => Prefs.instance.externalCalls), + _useTor = useTor ?? (() => Prefs.instance.useTor), + _supportsTor = + supportsTor ?? (() => AppConfig.hasFeature(AppFeature.tor)), + // Keep the public injection parameter named lookup. + // ignore: prefer_initializing_formals + _lookup = lookup, + _trustedValidators = List.unmodifiable( + trustedValidators ?? [DnssecResolver.validator], + ), + _torProxy = torProxy ?? (() => TorService.sharedInstance.getProxyInfo()); + + DnsOperation _startLookupDns( + String domain, + bool useTor, + ) { + final proxy = useTor ? _torProxy() : null; + final transport = IoDohTransport( + proxy: proxy == null + ? null + : SocksProxy( + proxy.host, + proxy.port, + // A fresh Tor circuit for each lookup. + credentials: SocksCredentials.isolation(_isolationToken()), + ), + proxyDestination: proxy == null ? null : InternetAddress('8.8.8.8'), + connectionTimeout: useTor ? torConnectTimeout : directConnectTimeout, + requestTimeout: useTor ? torRequestTimeout : directRequestTimeout, + ); + bool allowed() { + if (!_externalCalls() || _useTor() != useTor) return false; + if (useTor) { + if (!_supportsTor()) return false; + final current = _torProxy(); + return current.host.address == proxy!.host.address && + current.port == proxy.port; + } + return true; + } + + try { + final operation = DnssecResolver( + timeout: useTor ? torLookupTimeout : directLookupTimeout, + transport: OpenAliasDnsTransport(transport, allowed), + ).startTxtLookup(domain); + return DnsOperation( + operation.result + .then((result) { + if (!allowed()) { + throw const OpenAliasException( + 'Privacy settings changed. Please look up the alias again.', + ); + } + return result; + }) + .whenComplete(transport.close), + operation.cancel, + ); + } catch (_) { + unawaited(transport.close()); + rethrow; + } + } + + static String _isolationToken() { + final random = Random.secure(); + return List.generate( + 16, + (_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'), + ).join(); + } + + Future resolve( + String input, { + required bool Function(String) validateAddress, + }) => startResolve(input, validateAddress: validateAddress).result; + + // Cancellation owns the whole DNSSEC chain, including pending key requests. + DnsOperation startResolve( + String input, { + required bool Function(String) validateAddress, + }) { + final result = Completer(); + DnsOperation? lookup; + _resolve( + input, + validateAddress: validateAddress, + startLookup: (domain, useTor) { + final injected = _lookup; + lookup = injected == null + ? _startLookupDns(domain, useTor) + : DnsOperation(injected(domain, useTor), () {}); + return lookup!.result; + }, + ).then( + (recipient) { + if (!result.isCompleted) result.complete(recipient); + }, + onError: (Object error, StackTrace stack) { + if (!result.isCompleted) result.completeError(error, stack); + }, + ); + return DnsOperation(result.future, () { + if (result.isCompleted) return; + result.completeError( + const OpenAliasException('OpenAlias lookup cancelled.'), + ); + lookup?.cancel(); + }); + } + + Future _resolve( + String input, { + required bool Function(String) validateAddress, + required Future Function(String, bool) startLookup, + }) 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 recipient = await oa.OpenAliasResolver( + application: 'xmr', + requiredAuthentication: DnsAuthentication.locallyValidated, + trustedValidators: _trustedValidators, + validateAddress: validateAddress, + lookup: (name) async { + final result = await startLookup(name, useTor); + if (_useTor() != useTor || + !_externalCalls() || + (useTor && !_supportsTor())) { + throw const OpenAliasException( + 'Privacy settings changed. Please look up the alias again.', + ); + } + return result; + }, + ).resolve(domain); + return OpenAliasRecipient( + domain: recipient.domain, + address: recipient.address, + resolved: recipient, + ); + } on OpenAliasException { + rethrow; + } on oa.OpenAliasException catch (error) { + throw OpenAliasException(switch (error.code) { + oa.OpenAliasError.authenticationUnavailable => + 'Local DNSSEC verification failed. ' + 'Ask the recipient for a verified alias or address.', + oa.OpenAliasError.noRecord => + 'No Monero OpenAlias record was found for $domain.', + oa.OpenAliasError.invalidAddress => + 'This alias contains an invalid Monero address for this wallet.', + oa.OpenAliasError.ambiguous => + 'This alias has multiple Monero addresses. ' + 'Ask the recipient for an address.', + oa.OpenAliasError.questionMismatch => + 'The DNS response does not match this alias.', + _ => error.message, + }); + } on DnsException catch (error) { + throw OpenAliasException(switch (error.code) { + DnsError.authenticationUnavailable => + 'DNSSEC verification failed or is unavailable. ' + 'Ask the recipient for a verified alias or address.', + DnsError.noRecords => 'No OpenAlias record exists for $domain.', + DnsError.queryFailed => + 'The DNS lookup for $domain failed. No address was accepted.', + DnsError.questionMismatch => + 'The DNS response does not match this alias.', + DnsError.aliasLoop => 'The alias has a DNS loop.', + DnsError.aliasAmbiguous => 'The DNS alias is ambiguous.', + DnsError.aliasTooLong => 'The DNS alias chain is too long.', + DnsError.responseTooLarge => 'The DNS response is too large.', + DnsError.malformedResponse || + DnsError.invalidName => 'Invalid DNS response.', + DnsError.transport || DnsError.cancelled || DnsError.timedOut => + 'OpenAlias lookup failed. Check your connection and try again.', + }); + } catch (_) { + throw const OpenAliasException( + 'OpenAlias lookup is unavailable. ' + 'Check your connection and Tor settings.', + ); + } + } +} + +class OpenAliasDnsTransport implements DohWireTransport { + final DohWireTransport transport; + final bool Function() allowed; + OpenAliasDnsTransport(this.transport, this.allowed); + + @override + DnsOperation startWire( + Uri uri, + List query, { + required int maxResponseBytes, + }) { + // Key lookups need current permission, just like the initial TXT query. + if (!allowed()) { + throw const DnsException( + DnsError.cancelled, + 'DNS privacy settings changed', + ); + } + return transport.startWire(uri, query, maxResponseBytes: maxResponseBytes); + } +} diff --git a/lib/services/openalias/send_preview.dart b/lib/services/openalias/send_preview.dart new file mode 100644 index 0000000000..2629acede0 --- /dev/null +++ b/lib/services/openalias/send_preview.dart @@ -0,0 +1,143 @@ +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), + resolved: recipient.resolved, + ), + ); + } +} + +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..e962917764 --- /dev/null +++ b/lib/services/openalias/send_recipient.dart @@ -0,0 +1,51 @@ +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(input), + ); + } on OpenAliasException { + // Invalid input stays editable without initiating a lookup. + } + } + return SendRecipient._(SendRecipientKind.invalid, destination); + } + + bool get isAlias => kind == SendRecipientKind.openAlias; +} + +// Pasted/scanned literal addresses retain the existing first-line cleanup. +// Aliases must reach normalization unchanged so hidden input stays invalid. +String prepareSendRecipientInput( + String input, { + required bool supportsOpenAlias, + required bool Function(String) validateAddress, +}) { + final destination = input.trim().split('\n').first.trim(); + // Payment URIs retain paste cleanup; a URI cannot be an OpenAlias. + if (Uri.tryParse(destination)?.hasScheme ?? false) return destination; + return supportsOpenAlias && !validateAddress(destination) + ? input + : destination; +} diff --git a/lib/utilities/address_utils.dart b/lib/utilities/address_utils.dart index cb57e48e81..95f9370b74 100644 --- a/lib/utilities/address_utils.dart +++ b/lib/utilities/address_utils.dart @@ -83,8 +83,8 @@ class AddressUtils { rethrow; } catch (e, s) { Logging.instance.d( - "Exception caught in parseUri($uri): $e", - error: e, + "Payment URI parsing failed", + error: e.runtimeType.toString(), stackTrace: s, ); } @@ -140,10 +140,11 @@ class AddressUtils { // hacky check its not just a bcash, ecash, or xel address const cashAddrSchemes = {"bitcoincash", "bchtest", "ecash", "ectest"}; final parsedUri = Uri.tryParse(uri); - final scheme = parsedUri?.scheme.toLowerCase(); - if (parsedUri != null && - (scheme == "xel" || - (!parsedUri.hasQuery && cashAddrSchemes.contains(scheme)))) { + // Plain addresses and aliases are normal input, not failed payment URIs. + if (parsedUri == null || !parsedUri.hasScheme) return null; + final scheme = parsedUri.scheme.toLowerCase(); + if (scheme == "xel" || + (!parsedUri.hasQuery && cashAddrSchemes.contains(scheme))) { return null; } @@ -153,7 +154,16 @@ class AddressUtils { // Normalize the URI scheme. final String scheme = parsedData['scheme'] ?? ''; parsedData.remove('scheme'); - final address = parsedData['address']!.trim(); + final rawAddress = parsedData['address']!; + // Monero destinations are printable ASCII. Do not strip controls from an + // alias inside a payment URI before the alias validator can reject it. + if (scheme == 'monero' && + rawAddress.codeUnits.any( + (unit) => unit > 126 || (unit < 32 && unit != 9), + )) { + throw const FormatException('Invalid characters in Monero recipient'); + } + final address = rawAddress.trim(); // Filter out unrecognized parameters. final filteredParams = _filterParams(parsedData); @@ -171,7 +181,12 @@ class AddressUtils { additionalParams: filteredParams, ); } catch (e, s) { - logging?.i("Invalid payment URI: $uri", error: e, stackTrace: s); + // URI text and exception messages can contain payment recipients/notes. + logging?.i( + "Invalid payment URI", + error: e.runtimeType.toString(), + stackTrace: s, + ); return null; } } @@ -220,14 +235,17 @@ class AddressUtils { } /// returns empty if bad data - static Map decodeQRSeedData(String data) { + static Map decodeQRSeedData( + String data, { + Logging? logging, + }) { Map result = {}; try { result = Map.from(jsonDecode(data) as Map); } catch (e, s) { - Logging.instance.d( - "Exception caught in parseQRSeedData($data)", - error: e, + (logging ?? Logging.instance).d( + "QR seed data parsing failed", + error: e.runtimeType.toString(), stackTrace: s, ); } diff --git a/lib/utilities/test_monero_node_connection.dart b/lib/utilities/test_monero_node_connection.dart index 6a4fbfa455..1eb8181fa8 100644 --- a/lib/utilities/test_monero_node_connection.dart +++ b/lib/utilities/test_monero_node_connection.dart @@ -16,7 +16,7 @@ import 'package:flutter/material.dart'; import 'package:http/io_client.dart'; import 'package:monero_rpc/monero_rpc.dart'; import 'package:socks5_proxy/socks.dart'; -import 'package:socks_socket/socks_socket.dart'; +import 'package:socks_socket/socks.dart'; import '../widgets/desktop/primary_button.dart'; import '../widgets/desktop/secondary_button.dart'; @@ -30,12 +30,7 @@ class MoneroNodeConnectionResponse { final int? port; final bool success; - MoneroNodeConnectionResponse( - this.cert, - this.url, - this.port, - this.success, - ); + MoneroNodeConnectionResponse(this.cert, this.url, this.port, this.success); } Future testMoneroNodeConnection( @@ -43,10 +38,7 @@ Future testMoneroNodeConnection( String? username, String? password, bool allowBadX509Certificate, { - required ({ - InternetAddress host, - int port, - })? proxyInfo, + required ({InternetAddress host, int port})? proxyInfo, }) async { if (uri.host.endsWith(".onion")) { if (proxyInfo == null) { @@ -77,22 +69,27 @@ Future testMoneroNodeConnection( if (line.contains('WWW-authenticate: ')) { // both the password and username needs to be if (username == null || password == null) { - // node asking us for authentication, but we don't have any crendentials. + // The node requires authentication, but credentials are missing. return MoneroNodeConnectionResponse(null, null, null, false); } - authenticateHeaderValue = - line.replaceFirst('WWW-authenticate: ', '').trim(); + authenticateHeaderValue = line + .replaceFirst('WWW-authenticate: ', '') + .trim(); } } - // header to authenticate was present, we need to remake the request with digest + // Retry with digest authentication when the server requests it. if (authenticateHeaderValue != null) { final digestAuth = DigestAuth(username!, password!); digestAuth.initFromAuthorizationHeader(authenticateHeaderValue); // generate the Authorization header for the second request. final authHeader = digestAuth.getAuthString('POST', uri.path); - final rawRequestAuthenticated = - DaemonRpc.rawRequestRpc(uri, 'get_info', {}, authHeader); + final rawRequestAuthenticated = DaemonRpc.rawRequestRpc( + uri, + 'get_info', + {}, + authHeader, + ); // resend with an authenticated request response = await socket.send(rawRequestAuthenticated); } @@ -103,10 +100,12 @@ Future testMoneroNodeConnection( return MoneroNodeConnectionResponse(null, null, null, success); } catch (e, s) { - Logging.instance.w("$e\n$s", error: e, stackTrace: s,); + Logging.instance.w("$e\n$s", error: e, stackTrace: s); return MoneroNodeConnectionResponse(null, null, null, false); } finally { - await socket?.close(); + // The probe is finished; close() could rethrow an already handled + // failure. + socket?.destroy(); } } else { final httpClient = HttpClient(); @@ -114,10 +113,7 @@ Future testMoneroNodeConnection( try { if (proxyInfo != null) { SocksTCPClient.assignToHttpClient(httpClient, [ - ProxySettings( - proxyInfo.host, - proxyInfo.port, - ), + ProxySettings(proxyInfo.host, proxyInfo.port), ]); } @@ -127,8 +123,12 @@ Future testMoneroNodeConnection( } if (badCertResponse == null) { - badCertResponse = - MoneroNodeConnectionResponse(cert, url, port, false); + badCertResponse = MoneroNodeConnectionResponse( + cert, + url, + port, + false, + ); } else { return false; } @@ -150,7 +150,7 @@ Future testMoneroNodeConnection( if (badCertResponse != null) { return badCertResponse!; } else { - Logging.instance.w("$e\n$s", error: e, stackTrace: s,); + Logging.instance.w("$e\n$s", error: e, stackTrace: s); return MoneroNodeConnectionResponse(null, null, null, false); } } finally { @@ -204,14 +204,31 @@ Future showBadX509CertificateDialog( extension on SOCKSSocket { /// write the raw request to the socket and return the response as String Future send(String rawRequest) async { - write(rawRequest); + await write(rawRequest); final buffer = StringBuffer(); - await for (final response in inputStream) { - buffer.write(utf8.decode(response)); - if (buffer.toString().contains("\r\n\r\n")) { + await for (final response in inputStream.transform(utf8.decoder)) { + buffer.write(response); + if (_isComplete(buffer.toString())) { break; } } return buffer.toString(); } } + +/// Whether [response] has its headers and, if it declares a Content-Length, +/// its whole body. Over Tor the body often arrives after the headers. +bool _isComplete(String response) { + final headerEnd = response.indexOf("\r\n\r\n"); + if (headerEnd < 0) { + return false; + } + final length = RegExp( + r"^content-length:\s*(\d+)", + caseSensitive: false, + multiLine: true, + ).firstMatch(response.substring(0, headerEnd))?.group(1); + return length == null || + utf8.encode(response.substring(headerEnd + 4)).length >= + int.parse(length); +} diff --git a/lib/wallets/models/tx_data.dart b/lib/wallets/models/tx_data.dart index 16d6673f00..3b89c64451 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'; @@ -57,6 +58,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; @@ -137,6 +144,7 @@ class TxData { this.noteOnChain, this.memo, this.recipients, + this._openAliasRecipient, this.utxos, this.usedUTXOs, this.changeAddress, @@ -287,6 +295,7 @@ class TxData { Set? utxos, List? usedUTXOs, List? recipients, + OpenAliasRecipient? openAliasRecipient, String? frostMSConfig, List? frostSigners, String? changeAddress, @@ -341,6 +350,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/macos/Podfile b/macos/Podfile index e28f7e3b51..09e521a3ec 100644 --- a/macos/Podfile +++ b/macos/Podfile @@ -45,3 +45,12 @@ post_install do |installer| end end end + +# Hosted legacy framework archives flattened their version symlinks. Repair only +# staged copies and retain CocoaPods' configured signing identity/permissions. +require_relative '../scripts/macos/normalize_frameworks' +post_integrate do |installer| + scripts = Dir[File.join(installer.sandbox.root.to_s, 'Target Support Files', 'Pods-Runner', 'Pods-Runner-frameworks.sh')] + raise 'Expected exactly one Runner framework embed script' unless scripts.length == 1 + StackFrameworkPackaging.patch_embed_script(scripts.first) +end diff --git a/pubspec.lock b/pubspec.lock index 1b3b953206..858693a2a2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -829,6 +829,24 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + dnssec_resolver: + dependency: "direct main" + description: + path: "." + ref: main + resolved-ref: "5491d174bcfaa076ec6b49bcb37717a9287eb79a" + url: "https://github.com/cypherstack/dnssec_resolver.git" + source: git + version: "0.0.1" + doh_resolver: + dependency: "direct main" + description: + path: "." + ref: main + resolved-ref: e1d060d0c287f2bd00485d74848d1624ee6b5b00 + url: "https://github.com/cypherstack/doh_resolver.git" + source: git + version: "0.0.1" drift: dependency: "direct main" description: @@ -889,11 +907,11 @@ packages: dependency: "direct main" description: path: "." - ref: b6fa44d015d3bfa06934b73219928c29ca48a290 - resolved-ref: b6fa44d015d3bfa06934b73219928c29ca48a290 + ref: main + resolved-ref: "76ffad6ecc1e31de47d9187fd39c831db0aa987f" url: "https://github.com/cypherstack/electrum_adapter.git" source: git - version: "3.0.2" + version: "4.0.1" emojis: dependency: "direct main" description: @@ -1232,8 +1250,8 @@ packages: dependency: "direct main" description: path: "." - ref: "14427bcbbe1e754bce4a1b93cdb0a31ce56d792b" - resolved-ref: "14427bcbbe1e754bce4a1b93cdb0a31ce56d792b" + ref: main + resolved-ref: "7548bf1c6e79a82e991c3feb61c1908dfe324275" url: "https://github.com/cypherstack/fusiondart.git" source: git version: "1.0.0" @@ -1750,6 +1768,15 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.0" + openalias: + dependency: "direct main" + description: + path: "." + ref: main + resolved-ref: b6af74eda9e5a627381bdfd0e96a604409b0b95e + url: "https://github.com/cypherstack/openalias.git" + source: git + version: "0.0.1" package_config: dependency: transitive description: @@ -2140,13 +2167,13 @@ packages: source: hosted version: "2.1.1" socks_socket: - dependency: transitive + dependency: "direct main" description: name: socks_socket - sha256: "53bc7eae40a3aa16ea810b0e9de3bb23ba7beb0b40d09357b89190f2f44374cc" + sha256: "09149ed1278584554aeacc60c7aeec5bd80154e6ce1136f00d97485d09d5a338" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "2.0.0" solana: dependency: "direct main" description: @@ -2378,11 +2405,11 @@ packages: dependency: "direct main" description: path: "." - ref: "5586bfa3c48e495bc00f9b56fa18869daf768573" - resolved-ref: "5586bfa3c48e495bc00f9b56fa18869daf768573" + ref: "feat/bump-arti" + resolved-ref: "23ac35b417e38becf4189d90da29414ed2318824" url: "https://github.com/cypherstack/tor.git" source: git - version: "0.1.0" + version: "0.2.0" tuple: dependency: "direct main" description: diff --git a/scripts/app_config/templates/pubspec.template.yaml b/scripts/app_config/templates/pubspec.template.yaml index 60e89c7b7f..bcfe9df341 100644 --- a/scripts/app_config/templates/pubspec.template.yaml +++ b/scripts/app_config/templates/pubspec.template.yaml @@ -68,7 +68,7 @@ dependencies: # tor_ffi_plugin: # git: # url: https://github.com/cypherstack/tor.git -# ref: 5586bfa3c48e495bc00f9b56fa18869daf768573 +# ref: feat/bump-arti # %%END_ENABLE_TOR%% # %%ENABLE_XMR%% @@ -117,10 +117,14 @@ dependencies: fusiondart: git: url: https://github.com/cypherstack/fusiondart.git - ref: 14427bcbbe1e754bce4a1b93cdb0a31ce56d792b + ref: main # Utility plugins http: ^1.6.0 + openalias: ^0.0.1 + doh_resolver: ^0.0.1 + dnssec_resolver: ^0.0.1 + socks_socket: ^2.0.0 local_auth: ^2.3.0 permission_handler: ^13.0.1 flutter_local_notifications: ^22.3.0 @@ -229,7 +233,7 @@ dependencies: electrum_adapter: git: url: https://github.com/cypherstack/electrum_adapter.git - ref: b6fa44d015d3bfa06934b73219928c29ca48a290 + ref: main stream_channel: ^2.1.0 solana: git: # TODO [prio=low]: Revert to official package once Tor support is merged upstream. @@ -308,6 +312,20 @@ flutter_native_splash: color_dark: "2A2D34" dependency_overrides: + # Track the package review branches until the initial releases are published. + doh_resolver: + git: + url: https://github.com/cypherstack/doh_resolver.git + ref: main + dnssec_resolver: + git: + url: https://github.com/cypherstack/dnssec_resolver.git + ref: main + openalias: + git: + url: https://github.com/cypherstack/openalias.git + ref: main + # required for: native_toolchain_rust >=1.0.3 depends on toml ^0.18.0 # stellar sdk package needs to get 0.18.0 to remove this override toml: ^0.18.0 @@ -374,9 +392,8 @@ dependency_overrides: #hooks: # user_defines: # tor_ffi_plugin: -# native_build: prebuilt -# prebuilt_manifest_url: "https://github.com/cypherstack/tor/releases/download/native-0.1.0/manifest.json" -# prebuilt_manifest_sha256: "00a988a2c0994dff300bb19493e935b0d4eae2b506d02af9dd69ddad65805a24" +# # Use source builds until prebuilts match the review branch. +# native_build: source # flutter_libepiccash: # native_build: prebuilt # prebuilt_manifest_url: "https://github.com/cypherstack/flutter_libepiccash/releases/download/native-0.3.0/manifest.json" diff --git a/scripts/dev/test_use_openalias_packages.py b/scripts/dev/test_use_openalias_packages.py new file mode 100644 index 0000000000..ab9d2a9b8c --- /dev/null +++ b/scripts/dev/test_use_openalias_packages.py @@ -0,0 +1,62 @@ +"""Exercise local resolver overrides against a configured app with Git pins. + +Run with python3 scripts/dev/test_use_openalias_packages.py; requires Dart. +No network or wallet state is used. +""" + +import json +from pathlib import Path +import subprocess +import tempfile +import unittest + + +HELPER = Path(__file__).with_name('use_openalias_packages.dart').resolve() + + +class LocalOverridesTest(unittest.TestCase): + def test_replaces_existing_pins_and_preserves_other_overrides(self): + with tempfile.TemporaryDirectory(prefix='alias overrides ') as directory: + root = Path(directory) + packages = ['doh_resolver', 'dnssec_resolver', 'openalias', 'keep'] + for name in packages: + package = root / name + package.mkdir() + (package / 'pubspec.yaml').write_text( + f'name: {name}\nversion: 0.0.1\n' + 'environment:\n sdk: ">=3.5.0 <4.0.0"\n' + ) + app = root / 'app' + app.mkdir() + dependencies = ''.join(f' {name}: any\n' for name in packages) + pins = ''.join( + f' {name}:\n git:\n' + f' url: https://invalid.example/{name}.git\n' + ' ref: main\n' + for name in packages[:-1] + ) + (app / 'pubspec.yaml').write_text( + 'name: override_fixture\nenvironment:\n' + ' sdk: ">=3.5.0 <4.0.0"\ndependencies:\n' + dependencies + + 'dependency_overrides:\n' + pins + + ' keep:\n path: ../keep\n' + ) + def run(*args): + return subprocess.run( + ['dart', *args], cwd=app, check=True, + capture_output=True, text=True, timeout=60, + ) + run(str(HELPER), str(root)) + first = (app / 'pubspec_overrides.yaml').read_text() + run(str(HELPER), str(root)) + self.assertEqual(first, (app / 'pubspec_overrides.yaml').read_text()) + run('pub', 'get', '--offline') + config = json.loads((app / '.dart_tool/package_config.json').read_text()) + resolved = {item['name']: item for item in config['packages']} + for name in packages: + uri = resolved[name]['rootUri'] + self.assertTrue(uri.endswith(f'/{name}') or uri.endswith(f'/{name}/')) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/dev/use_openalias_packages.dart b/scripts/dev/use_openalias_packages.dart new file mode 100644 index 0000000000..3c96cf6e40 --- /dev/null +++ b/scripts/dev/use_openalias_packages.dart @@ -0,0 +1,54 @@ +import 'dart:convert'; +import 'dart:io'; + +void main(List args) { + if (args.length != 1 || !File('pubspec.yaml').existsSync()) { + throw ArgumentError( + 'Run from the configured app root with the package workspace path.', + ); + } + final workspace = Directory(args.single).absolute; + const packages = ['doh_resolver', 'dnssec_resolver', 'openalias']; + for (final package in packages) { + if (!File('${workspace.path}/$package/pubspec.yaml').existsSync()) { + throw ArgumentError('Missing package $package in ${workspace.path}'); + } + } + + // Pub replaces the whole override map; retain the app's overrides. + final lines = File('pubspec.yaml').readAsLinesSync(); + final start = lines.indexOf('dependency_overrides:'); + if (start < 0) { + throw StateError('Expected generated app dependency overrides'); + } + final overrides = []; + var skippingReplacedPackage = false; + final overrideKey = RegExp(r'^ ([A-Za-z0-9_]+):(?:\s.*)?$'); + for (final line in lines.skip(start + 1)) { + if (line.isNotEmpty && !line.startsWith(' ') && !line.startsWith('#')) { + break; + } + if (skippingReplacedPackage) { + final indentation = line.length - line.trimLeft().length; + if (line.trim().isNotEmpty && indentation > 2) { + continue; + } + skippingReplacedPackage = false; + } + final match = overrideKey.firstMatch(line); + if (match != null && packages.contains(match.group(1))) { + skippingReplacedPackage = true; + continue; + } + overrides.add(line); + } + final localOverrides = packages.map((package) { + final path = jsonEncode('${workspace.path}/$package'); + return ' $package:\n path: $path\n'; + }).join(); + File('pubspec_overrides.yaml').writeAsStringSync( + 'dependency_overrides:\n${overrides.join('\n')}\n' + '$localOverrides', + ); + stdout.writeln('Wrote local package overrides; run flutter pub get next.'); +} diff --git a/scripts/dev/verify_macos_deployment.py b/scripts/dev/verify_macos_deployment.py new file mode 100755 index 0000000000..b816e7963d --- /dev/null +++ b/scripts/dev/verify_macos_deployment.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Check a built app's advertised macOS floor against every embedded Mach-O slice. + +Usage: python3 scripts/dev/verify_macos_deployment.py path/to/Wallet.app +Read-only. Exit 0 means declared deployment targets fit, 1 means a mismatch, and 2 means +verification could not complete. Requires the Xcode command-line tools. +""" + +import argparse +import plistlib +import re +import subprocess +import sys +from pathlib import Path + +MACHO_MAGIC = { + bytes.fromhex(value) + for value in ( + "feedface", "cefaedfe", "feedfacf", "cffaedfe", + "cafebabe", "bebafeca", "cafebabf", "bfbafeca", + ) +} + + +def version(value): + if not isinstance(value, str) or not re.fullmatch(r"\d+(?:\.\d+){0,2}", value): + raise ValueError(f"Invalid macOS version: {value!r}") + parts = tuple(map(int, value.split("."))) + return parts + (0,) * (3 - len(parts)) + + +def run_tool(*args): + result = subprocess.run( + ["xcrun", *args], check=True, capture_output=True, text=True, timeout=30, + ) + return result.stdout + + +def minimum_version(binary, architecture): + output = run_tool("vtool", "-arch", architecture, "-show-build", str(binary)) + command = None + platform = None + found = [] + for line in output.splitlines(): + fields = line.split() + if len(fields) != 2: + continue + key, value = fields + if key == "cmd": + command, platform = value, None + elif key == "platform": + platform = value + elif command == "LC_BUILD_VERSION" and key == "minos": + if platform not in ("MACOS", "1"): + raise ValueError(f"{binary} [{architecture}] targets {platform}, not macOS") + found.append(value) + elif command == "LC_VERSION_MIN_MACOSX" and key == "version": + found.append(value) + if len(found) != 1: + raise ValueError(f"{binary} [{architecture}]: expected one macOS deployment load command") + return found[0] + + +def verify(app): + contents = app / "Contents" + with (contents / "Info.plist").open("rb") as stream: + advertised = plistlib.load(stream).get("LSMinimumSystemVersion") + floor = version(advertised) + seen = set() + slices = 0 + failures = [] + for entry in sorted(contents.rglob("*")): + if not entry.is_file(): + continue + resolved = entry.resolve() + if resolved in seen: + continue + seen.add(resolved) + with entry.open("rb") as stream: + if stream.read(4) not in MACHO_MAGIC: + continue + architectures = run_tool("lipo", "-archs", str(entry)).split() + if not architectures: + raise ValueError(f"No Mach-O architectures found: {entry}") + for architecture in architectures: + minimum = minimum_version(entry, architecture) + slices += 1 + if version(minimum) > floor: + failures.append( + f"{entry.relative_to(contents)} [{architecture}] requires macOS {minimum}" + ) + if not slices: + raise ValueError("No embedded Mach-O slices found; cannot verify this app") + print(f"App advertises macOS {advertised}; inspected {slices} Mach-O slices.") + for failure in failures: + print(f"FAIL: {failure}") + if failures: + print(f"Deployment mismatch: {len(failures)} slices require a newer OS.") + return 1 + print("PASS: embedded Mach-O deployment targets do not exceed the advertised minimum.") + return 0 + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("app", type=Path) + args = parser.parse_args() + try: + return verify(args.app) + except (OSError, ValueError, plistlib.InvalidFileException, subprocess.SubprocessError) as error: + print(f"Verification incomplete: {error}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/macos/normalize_frameworks.rb b/scripts/macos/normalize_frameworks.rb new file mode 100644 index 0000000000..f9a74d89e7 --- /dev/null +++ b/scripts/macos/normalize_frameworks.rb @@ -0,0 +1,111 @@ +# Repair flattened symlinks in the three legacy hosted frameworks, only after +# CocoaPods has copied them into the app. Never modify package/cache sources. +require 'digest' +require 'fileutils' +require 'pathname' +require 'shellwords' + +module StackFrameworkPackaging + NAMES = %w[MoneroWallet WowneroWallet SalviumWallet].freeze + PARALLEL = 'COCOAPODS_PARALLEL_CODE_SIGN="${COCOAPODS_PARALLEL_CODE_SIGN:-false}"'.freeze + INSERTION = " local basename\n".freeze + MARKER = '# Stack Wallet: validate staged legacy framework layout before stripping/signing.'.freeze + + def self.inventory(path) + raise "Expected a real directory: #{path}" unless path.directory? && !path.symlink? + entries = {} + path.children.sort.each do |entry| + raise "Unexpected symlink in flattened framework: #{entry}" if entry.symlink? + if entry.directory? + entries[entry.basename.to_s] = [:directory, inventory(entry)] + elsif entry.file? + entries[entry.basename.to_s] = [:file, entry.size, Digest::SHA256.file(entry).hexdigest] + else + raise "Unexpected filesystem entry: #{entry}" + end + end + entries + end + + def self.normalize(staging_root, framework) + path = Pathname.new(framework).expand_path + name = path.basename.to_s.delete_suffix('.framework') + return unless NAMES.include?(name) && path.basename.to_s == "#{name}.framework" + + root = Pathname.new(staging_root).realpath + unless !path.symlink? && path.directory? && path.parent.realpath == root + raise "Framework must be a direct staged copy: #{path}" + end + unless path.children.map { |entry| entry.basename.to_s }.sort == [name, 'Resources', 'Versions'].sort + raise "Unexpected top-level framework entries: #{path}" + end + versions = path / 'Versions' + canonical = versions / 'A' + raise "Unexpected Versions symlink: #{versions}" if versions.symlink? + unless versions.children.map { |entry| entry.basename.to_s }.sort == %w[A Current] + raise "Unexpected framework versions: #{versions}" + end + canonical_inventory = inventory(canonical) + unless (canonical / name).file? && (canonical / 'Resources').directory? + raise "Missing canonical framework binary/resources: #{path}" + end + links = { + versions / 'Current' => ['A', canonical], + path / name => ["Versions/Current/#{name}", canonical / name], + path / 'Resources' => ['Versions/Current/Resources', canonical / 'Resources'], + } + # Validate every entry before any mutation, including partially repaired layouts. + links.each do |entry, (target, original)| + if entry.symlink? + raise "Unexpected framework link: #{entry}" unless entry.readlink.to_s == target + elsif original.directory? + expected = original == canonical ? canonical_inventory : inventory(original) + raise "Nonidentical framework directory: #{entry}" unless inventory(entry) == expected + else + unless entry.file? && FileUtils.compare_file(entry, original) + raise "Nonidentical framework binary: #{entry}" + end + end + end + links.each do |entry, (target, _original)| + next if entry.symlink? + entry.directory? ? FileUtils.remove_entry(entry.to_s) : entry.unlink + entry.make_symlink(target) + end + end + + def self.patch_embed_script(path) + script = File.read(path) + helper = File.expand_path(__FILE__) + command = " /usr/bin/ruby #{Shellwords.escape(helper)} normalize \"${destination}\" \"${destination}/$(basename \"$1\")\"\n" + replacement = " #{MARKER}\n#{command}#{INSERTION}" + if script.include?(MARKER) + unless script.scan(replacement).length == 1 && script.scan('COCOAPODS_PARALLEL_CODE_SIGN=false').length == 1 && !script.include?(PARALLEL) + raise "Unexpected previously patched CocoaPods embed script: #{path}" + end + return + end + boundaries = /install_framework\(\)\n\{\n.*?\n\}\n/m + sections = script.scan(boundaries) + raise "Expected one install_framework function: #{path}" unless sections.length == 1 + section = sections.first + copy = ' rsync --delete -av ' + + sign = ' code_sign_if_enabled "${destination}/$(basename "$1")"' + unless script.scan(PARALLEL).length == 1 && section.scan(INSERTION).length == 1 && + section.scan(copy).length == 1 && section.scan(sign).length == 1 && + section.index(copy) < section.index(INSERTION) && section.index(INSERTION) < section.index(sign) + raise "CocoaPods embed script changed; review staged framework repair integration: #{path}" + end + script = script.sub(PARALLEL) { 'COCOAPODS_PARALLEL_CODE_SIGN=false' } + script = script.sub(section) { section.sub(INSERTION) { replacement } } + File.write(path, script) + end +end + +if $PROGRAM_NAME == __FILE__ + unless ARGV.length == 3 && ARGV[0] == 'normalize' + abort 'Usage: normalize_frameworks.rb normalize STAGING_FRAMEWORKS_DIR FRAMEWORK' + end + StackFrameworkPackaging.normalize(ARGV[1], ARGV[2]) +end diff --git a/scripts/macos/test/fixtures/COCOAPODS_LICENSE b/scripts/macos/test/fixtures/COCOAPODS_LICENSE new file mode 100644 index 0000000000..6e8a60d2df --- /dev/null +++ b/scripts/macos/test/fixtures/COCOAPODS_LICENSE @@ -0,0 +1,33 @@ +This project is licensed under the MIT license. + +Copyright (c) 2011 Eloy Durán , + Fabio Pelosin , + Samuel Giddins , + Marius Rackwitz , + Kyle Fuller , + Boris Bügling , + Orta Therox , + Olivier Halligon , + Danielle Tomlinson , + Dimitris Koutsogiorgas , + Paul Beusterien , and + Eric Amorde . + + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/scripts/macos/test/fixtures/README.md b/scripts/macos/test/fixtures/README.md new file mode 100644 index 0000000000..a5fdefc920 --- /dev/null +++ b/scripts/macos/test/fixtures/README.md @@ -0,0 +1,10 @@ +`cocoapods-frameworks.sh` was generated by CocoaPods 1.16.2 for this +workspace's Runner target. It preserves the real install_framework, +install_dsym, repeated rsync calls, and signing routines so our integration +checks fail when function-boundary assumptions are wrong. It is a test fixture, +not an executed application build script. + +The generator is CocoaPods' `lib/cocoapods/generator/embed_frameworks_script.rb`. +Its MIT license is reproduced in `COCOAPODS_LICENSE`. Regenerate the fixture +when intentionally supporting a changed CocoaPods script format, then rerun +`ruby scripts/macos/test/normalize_frameworks_test.rb`. diff --git a/scripts/macos/test/fixtures/cocoapods-frameworks.sh b/scripts/macos/test/fixtures/cocoapods-frameworks.sh new file mode 100644 index 0000000000..5a51f2f99e --- /dev/null +++ b/scripts/macos/test/fixtures/cocoapods-frameworks.sh @@ -0,0 +1,216 @@ +#!/bin/sh +set -e +set -u +set -o pipefail + +function on_error { + echo "$(realpath -mq "${0}"):$1: error: Unexpected failure" +} +trap 'on_error $LINENO' ERR + +if [ -z ${FRAMEWORKS_FOLDER_PATH+x} ]; then + # If FRAMEWORKS_FOLDER_PATH is not set, then there's nowhere for us to copy + # frameworks to, so exit 0 (signalling the script phase was successful). + exit 0 +fi + +echo "mkdir -p ${CONFIGURATION_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}" +mkdir -p "${CONFIGURATION_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}" + +COCOAPODS_PARALLEL_CODE_SIGN="${COCOAPODS_PARALLEL_CODE_SIGN:-false}" +SWIFT_STDLIB_PATH="${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" +BCSYMBOLMAP_DIR="BCSymbolMaps" + + +# This protects against multiple targets copying the same framework dependency at the same time. The solution +# was originally proposed here: https://lists.samba.org/archive/rsync/2008-February/020158.html +RSYNC_PROTECT_TMP_FILES=(--filter "P .*.??????") + +# Copies and strips a vendored framework +install_framework() +{ + if [ -r "${BUILT_PRODUCTS_DIR}/$1" ]; then + local source="${BUILT_PRODUCTS_DIR}/$1" + elif [ -r "${BUILT_PRODUCTS_DIR}/$(basename "$1")" ]; then + local source="${BUILT_PRODUCTS_DIR}/$(basename "$1")" + elif [ -r "$1" ]; then + local source="$1" + fi + + local destination="${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}" + + if [ -L "${source}" ]; then + echo "Symlinked..." + source="$(readlink -f "${source}")" + fi + + if [ -d "${source}/${BCSYMBOLMAP_DIR}" ]; then + # Locate and install any .bcsymbolmaps if present, and remove them from the .framework before the framework is copied + find "${source}/${BCSYMBOLMAP_DIR}" -name "*.bcsymbolmap"|while read f; do + echo "Installing $f" + install_bcsymbolmap "$f" "$destination" + rm "$f" + done + rmdir "${source}/${BCSYMBOLMAP_DIR}" + fi + + # Use filter instead of exclude so missing patterns don't throw errors. + echo "rsync --delete -av "${RSYNC_PROTECT_TMP_FILES[@]}" --links --filter \"- CVS/\" --filter \"- .svn/\" --filter \"- .git/\" --filter \"- .hg/\" --filter \"- Headers\" --filter \"- PrivateHeaders\" --filter \"- Modules\" \"${source}\" \"${destination}\"" + rsync --delete -av "${RSYNC_PROTECT_TMP_FILES[@]}" --links --filter "- CVS/" --filter "- .svn/" --filter "- .git/" --filter "- .hg/" --filter "- Headers" --filter "- PrivateHeaders" --filter "- Modules" "${source}" "${destination}" + + local basename + basename="$(basename -s .framework "$1")" + binary="${destination}/${basename}.framework/${basename}" + + if ! [ -r "$binary" ]; then + binary="${destination}/${basename}" + elif [ -L "${binary}" ]; then + echo "Destination binary is symlinked..." + dirname="$(dirname "${binary}")" + binary="${dirname}/$(readlink "${binary}")" + fi + + # Strip invalid architectures so "fat" simulator / device frameworks work on device + if [[ "$(file "$binary")" == *"dynamically linked shared library"* ]]; then + strip_invalid_archs "$binary" + fi + + # Resign the code if required by the build settings to avoid unstable apps + code_sign_if_enabled "${destination}/$(basename "$1")" + + # Embed linked Swift runtime libraries. No longer necessary as of Xcode 7. + if [ "${XCODE_VERSION_MAJOR}" -lt 7 ]; then + local swift_runtime_libs + swift_runtime_libs=$(xcrun otool -LX "$binary" | grep --color=never @rpath/libswift | sed -E s/@rpath\/\(.+dylib\).*/\1/g | uniq -u) + for lib in $swift_runtime_libs; do + echo "rsync -auv \"${SWIFT_STDLIB_PATH}/${lib}\" \"${destination}\"" + rsync -auv "${SWIFT_STDLIB_PATH}/${lib}" "${destination}" + code_sign_if_enabled "${destination}/${lib}" + done + fi +} +# Copies and strips a vendored dSYM +install_dsym() { + local source="$1" + warn_missing_arch=${2:-true} + if [ -r "$source" ]; then + # Copy the dSYM into the targets temp dir. + echo "rsync --delete -av "${RSYNC_PROTECT_TMP_FILES[@]}" --filter \"- CVS/\" --filter \"- .svn/\" --filter \"- .git/\" --filter \"- .hg/\" --filter \"- Headers\" --filter \"- PrivateHeaders\" --filter \"- Modules\" \"${source}\" \"${DERIVED_FILES_DIR}\"" + rsync --delete -av "${RSYNC_PROTECT_TMP_FILES[@]}" --filter "- CVS/" --filter "- .svn/" --filter "- .git/" --filter "- .hg/" --filter "- Headers" --filter "- PrivateHeaders" --filter "- Modules" "${source}" "${DERIVED_FILES_DIR}" + + local basename + basename="$(basename -s .dSYM "$source")" + binary_name="$(ls "$source/Contents/Resources/DWARF")" + binary="${DERIVED_FILES_DIR}/${basename}.dSYM/Contents/Resources/DWARF/${binary_name}" + + # Strip invalid architectures from the dSYM. + if [[ "$(file "$binary")" == *"Mach-O "*"dSYM companion"* ]]; then + strip_invalid_archs "$binary" "$warn_missing_arch" + fi + if [[ $STRIP_BINARY_RETVAL == 0 ]]; then + # Move the stripped file into its final destination. + echo "rsync --delete -av "${RSYNC_PROTECT_TMP_FILES[@]}" --links --filter \"- CVS/\" --filter \"- .svn/\" --filter \"- .git/\" --filter \"- .hg/\" --filter \"- Headers\" --filter \"- PrivateHeaders\" --filter \"- Modules\" \"${DERIVED_FILES_DIR}/${basename}.framework.dSYM\" \"${DWARF_DSYM_FOLDER_PATH}\"" + rsync --delete -av "${RSYNC_PROTECT_TMP_FILES[@]}" --links --filter "- CVS/" --filter "- .svn/" --filter "- .git/" --filter "- .hg/" --filter "- Headers" --filter "- PrivateHeaders" --filter "- Modules" "${DERIVED_FILES_DIR}/${basename}.dSYM" "${DWARF_DSYM_FOLDER_PATH}" + else + # The dSYM was not stripped at all, in this case touch a fake folder so the input/output paths from Xcode do not reexecute this script because the file is missing. + mkdir -p "${DWARF_DSYM_FOLDER_PATH}" + touch "${DWARF_DSYM_FOLDER_PATH}/${basename}.dSYM" + fi + fi +} + +# Used as a return value for each invocation of `strip_invalid_archs` function. +STRIP_BINARY_RETVAL=0 + +# Strip invalid architectures +strip_invalid_archs() { + binary="$1" + warn_missing_arch=${2:-true} + # Get architectures for current target binary + binary_archs="$(lipo -info "$binary" | rev | cut -d ':' -f1 | awk '{$1=$1;print}' | rev)" + # Intersect them with the architectures we are building for + intersected_archs="$(echo ${ARCHS[@]} ${binary_archs[@]} | tr ' ' '\n' | sort | uniq -d)" + # If there are no archs supported by this binary then warn the user + if [[ -z "$intersected_archs" ]]; then + if [[ "$warn_missing_arch" == "true" ]]; then + echo "warning: [CP] Vendored binary '$binary' contains architectures ($binary_archs) none of which match the current build architectures ($ARCHS)." + fi + STRIP_BINARY_RETVAL=1 + return + fi + stripped="" + for arch in $binary_archs; do + if ! [[ "${ARCHS}" == *"$arch"* ]]; then + # Strip non-valid architectures in-place + lipo -remove "$arch" -output "$binary" "$binary" + stripped="$stripped $arch" + fi + done + if [[ "$stripped" ]]; then + echo "Stripped $binary of architectures:$stripped" + fi + STRIP_BINARY_RETVAL=0 +} + +# Copies the bcsymbolmap files of a vendored framework +install_bcsymbolmap() { + local bcsymbolmap_path="$1" + local destination="${BUILT_PRODUCTS_DIR}" + echo "rsync --delete -av "${RSYNC_PROTECT_TMP_FILES[@]}" --filter "- CVS/" --filter "- .svn/" --filter "- .git/" --filter "- .hg/" --filter "- Headers" --filter "- PrivateHeaders" --filter "- Modules" "${bcsymbolmap_path}" "${destination}"" + rsync --delete -av "${RSYNC_PROTECT_TMP_FILES[@]}" --filter "- CVS/" --filter "- .svn/" --filter "- .git/" --filter "- .hg/" --filter "- Headers" --filter "- PrivateHeaders" --filter "- Modules" "${bcsymbolmap_path}" "${destination}" +} + +# Signs a framework with the provided identity +code_sign_if_enabled() { + if [ -n "${EXPANDED_CODE_SIGN_IDENTITY:-}" -a "${CODE_SIGNING_REQUIRED:-}" != "NO" -a "${CODE_SIGNING_ALLOWED}" != "NO" ]; then + # Use the current code_sign_identity + echo "Code Signing $1 with Identity ${EXPANDED_CODE_SIGN_IDENTITY_NAME}" + local code_sign_cmd="/usr/bin/codesign --force --sign ${EXPANDED_CODE_SIGN_IDENTITY} ${OTHER_CODE_SIGN_FLAGS:-} --preserve-metadata=identifier,entitlements '$1'" + + if [ "${COCOAPODS_PARALLEL_CODE_SIGN}" == "true" ]; then + code_sign_cmd="$code_sign_cmd &" + fi + echo "$code_sign_cmd" + eval "$code_sign_cmd" + fi +} + +if [[ "$CONFIGURATION" == "Debug" ]]; then + install_framework "${BUILT_PRODUCTS_DIR}/coinlib_flutter/secp256k1.framework" + install_framework "${PODS_ROOT}/../Flutter/ephemeral/.symlinks/plugins/cs_monero_flutter_libs_macos/macos/Frameworks/MoneroWallet.framework" + install_framework "${BUILT_PRODUCTS_DIR}/cs_monero_flutter_libs_macos/cs_monero_flutter_libs_macos.framework" + install_framework "${PODS_ROOT}/../Flutter/ephemeral/.symlinks/plugins/cs_salvium_flutter_libs_macos/macos/Frameworks/SalviumWallet.framework" + install_framework "${BUILT_PRODUCTS_DIR}/cs_salvium_flutter_libs_macos/cs_salvium_flutter_libs_macos.framework" + install_framework "${PODS_ROOT}/../Flutter/ephemeral/.symlinks/plugins/cs_wownero_flutter_libs_macos/macos/Frameworks/WowneroWallet.framework" + install_framework "${BUILT_PRODUCTS_DIR}/cs_wownero_flutter_libs_macos/cs_wownero_flutter_libs_macos.framework" + install_framework "${PODS_ROOT}/../Flutter/ephemeral/.symlinks/plugins/isar_community_flutter_libs/macos/libisar.dylib" + install_framework "${BUILT_PRODUCTS_DIR}/isar_community_flutter_libs/isar_community_flutter_libs.framework" + install_framework "${BUILT_PRODUCTS_DIR}/window_size/window_size.framework" +fi +if [[ "$CONFIGURATION" == "Profile" ]]; then + install_framework "${BUILT_PRODUCTS_DIR}/coinlib_flutter/secp256k1.framework" + install_framework "${PODS_ROOT}/../Flutter/ephemeral/.symlinks/plugins/cs_monero_flutter_libs_macos/macos/Frameworks/MoneroWallet.framework" + install_framework "${BUILT_PRODUCTS_DIR}/cs_monero_flutter_libs_macos/cs_monero_flutter_libs_macos.framework" + install_framework "${PODS_ROOT}/../Flutter/ephemeral/.symlinks/plugins/cs_salvium_flutter_libs_macos/macos/Frameworks/SalviumWallet.framework" + install_framework "${BUILT_PRODUCTS_DIR}/cs_salvium_flutter_libs_macos/cs_salvium_flutter_libs_macos.framework" + install_framework "${PODS_ROOT}/../Flutter/ephemeral/.symlinks/plugins/cs_wownero_flutter_libs_macos/macos/Frameworks/WowneroWallet.framework" + install_framework "${BUILT_PRODUCTS_DIR}/cs_wownero_flutter_libs_macos/cs_wownero_flutter_libs_macos.framework" + install_framework "${PODS_ROOT}/../Flutter/ephemeral/.symlinks/plugins/isar_community_flutter_libs/macos/libisar.dylib" + install_framework "${BUILT_PRODUCTS_DIR}/isar_community_flutter_libs/isar_community_flutter_libs.framework" + install_framework "${BUILT_PRODUCTS_DIR}/window_size/window_size.framework" +fi +if [[ "$CONFIGURATION" == "Release" ]]; then + install_framework "${BUILT_PRODUCTS_DIR}/coinlib_flutter/secp256k1.framework" + install_framework "${PODS_ROOT}/../Flutter/ephemeral/.symlinks/plugins/cs_monero_flutter_libs_macos/macos/Frameworks/MoneroWallet.framework" + install_framework "${BUILT_PRODUCTS_DIR}/cs_monero_flutter_libs_macos/cs_monero_flutter_libs_macos.framework" + install_framework "${PODS_ROOT}/../Flutter/ephemeral/.symlinks/plugins/cs_salvium_flutter_libs_macos/macos/Frameworks/SalviumWallet.framework" + install_framework "${BUILT_PRODUCTS_DIR}/cs_salvium_flutter_libs_macos/cs_salvium_flutter_libs_macos.framework" + install_framework "${PODS_ROOT}/../Flutter/ephemeral/.symlinks/plugins/cs_wownero_flutter_libs_macos/macos/Frameworks/WowneroWallet.framework" + install_framework "${BUILT_PRODUCTS_DIR}/cs_wownero_flutter_libs_macos/cs_wownero_flutter_libs_macos.framework" + install_framework "${PODS_ROOT}/../Flutter/ephemeral/.symlinks/plugins/isar_community_flutter_libs/macos/libisar.dylib" + install_framework "${BUILT_PRODUCTS_DIR}/isar_community_flutter_libs/isar_community_flutter_libs.framework" + install_framework "${BUILT_PRODUCTS_DIR}/window_size/window_size.framework" +fi +if [ "${COCOAPODS_PARALLEL_CODE_SIGN}" == "true" ]; then + wait +fi diff --git a/scripts/macos/test/normalize_frameworks_test.rb b/scripts/macos/test/normalize_frameworks_test.rb new file mode 100644 index 0000000000..04fd257055 --- /dev/null +++ b/scripts/macos/test/normalize_frameworks_test.rb @@ -0,0 +1,175 @@ +require 'minitest/autorun' +require 'tmpdir' +require 'open3' +require_relative '../normalize_frameworks' + +class NormalizeFrameworksTest < Minitest::Test + def setup + @tmp = Dir.mktmpdir('framework-packaging-test') + @root = Pathname.new(@tmp) / 'stage' + @root.mkdir + @framework = @root / 'MoneroWallet.framework' + @version = @framework / 'Versions/A' + FileUtils.mkdir_p(@version / 'Resources') + (@version / 'MoneroWallet').write('binary') + (@version / 'Resources/Info.plist').write('metadata') + FileUtils.cp_r(@version, @framework / 'Versions/Current') + FileUtils.cp(@version / 'MoneroWallet', @framework / 'MoneroWallet') + FileUtils.cp_r(@version / 'Resources', @framework / 'Resources') + end + + def teardown + FileUtils.remove_entry(@tmp) + end + + def normalize + StackFrameworkPackaging.normalize(@root, @framework) + end + + def assert_unmodified_after_failure + before = StackFrameworkPackaging.inventory(@framework) + assert_raises(RuntimeError) { normalize } + assert_equal before, StackFrameworkPackaging.inventory(@framework) + end + + def test_identical_copies_become_links_and_repeated_runs_preserve_signed_content + normalize + assert_equal 'A', (@framework / 'Versions/Current').readlink.to_s + assert_equal 'Versions/Current/MoneroWallet', (@framework / 'MoneroWallet').readlink.to_s + assert_equal 'Versions/Current/Resources', (@framework / 'Resources').readlink.to_s + FileUtils.mkdir_p(@version / '_CodeSignature') + (@version / '_CodeSignature/CodeResources').write('signed resource seal') + normalize + assert_equal 'signed resource seal', (@framework / 'Versions/Current/_CodeSignature/CodeResources').read + end + + def test_binary_mismatch_does_not_partially_repair_directories + (@framework / 'MoneroWallet').write('different') + assert_unmodified_after_failure + end + + def test_resource_mismatch_does_not_partially_repair_any_entry + (@framework / 'Resources/Info.plist').write('different') + assert_unmodified_after_failure + end + + def test_current_extra_signature_is_not_discarded + (@framework / 'Versions/Current/extra-signature').write('stale') + assert_unmodified_after_failure + end + + def test_extra_top_level_entry_is_rejected + (@framework / 'unexpected').write('extra') + assert_unmodified_after_failure + end + + def test_nested_symlink_is_rejected_before_mutation + (@version / 'Resources/escape').make_symlink(@root) + assert_raises(RuntimeError) { normalize } + refute (@framework / 'Versions/Current').symlink? + end + + def test_unexpected_main_symlink_is_rejected + FileUtils.remove_entry(@framework / 'Resources') + (@framework / 'Resources').make_symlink(@root) + assert_raises(RuntimeError) { normalize } + refute (@framework / 'Versions/Current').symlink? + end + + def test_source_outside_staging_is_rejected + other = Pathname.new(@tmp) / 'other' + other.mkdir + assert_raises(RuntimeError) { StackFrameworkPackaging.normalize(other, @framework) } + refute (@framework / 'Versions/Current').symlink? + end + + def test_unknown_framework_is_untouched + other = @root / 'Other.framework' + FileUtils.mv(@framework, other) + before = StackFrameworkPackaging.inventory(other) + StackFrameworkPackaging.normalize(@root, other) + assert_equal before, StackFrameworkPackaging.inventory(other) + end + + def test_embed_patch_is_idempotent_and_forces_synchronous_signing + path = Pathname.new(@tmp) / 'embed.sh' + path.write("#{StackFrameworkPackaging::PARALLEL}\ninstall_framework()\n{\n rsync --delete -av source dest\n#{StackFrameworkPackaging::INSERTION}" + + ' code_sign_if_enabled "${destination}/$(basename "$1")"' + "\n}\n") + StackFrameworkPackaging.patch_embed_script(path) + once = path.read + StackFrameworkPackaging.patch_embed_script(path) + assert_equal once, path.read + assert_includes once, 'COCOAPODS_PARALLEL_CODE_SIGN=false' + assert_operator once.index(' normalize '), :<, once.index(' local basename') + end + + def test_actual_cocoapods_script_with_dsym_and_other_rsync_calls + path = Pathname.new(@tmp) / 'embed.sh' + fixture = Pathname.new(__dir__) / 'fixtures/cocoapods-frameworks.sh' + path.write(fixture.read) + StackFrameworkPackaging.patch_embed_script(path) + once = path.read + StackFrameworkPackaging.patch_embed_script(path) + assert_equal once, path.read + assert_equal 1, once.scan(' normalize ').length + assert_includes once, 'codesign --force --sign ${EXPANDED_CODE_SIGN_IDENTITY}' + assert_operator once.index(' normalize '), :<, once.index(' strip_invalid_archs "$binary"') + assert_operator once.index(' normalize '), :>, once.index(' rsync --delete -av ') + assert system('/bin/bash', '-n', path.to_s) + end + + def test_altered_previous_patch_fails_without_editing + path = Pathname.new(@tmp) / 'embed.sh' + path.write((Pathname.new(__dir__) / 'fixtures/cocoapods-frameworks.sh').read) + StackFrameworkPackaging.patch_embed_script(path) + path.write(path.read.sub('COCOAPODS_PARALLEL_CODE_SIGN=false', 'COCOAPODS_PARALLEL_CODE_SIGN=true')) + before = path.read + assert_raises(RuntimeError) { StackFrameworkPackaging.patch_embed_script(path) } + assert_equal before, path.read + end + + def test_failed_signing_stops_script_and_preserves_identity_and_disabled_signing + path = Pathname.new(@tmp) / 'embed.sh' + path.write((Pathname.new(__dir__) / 'fixtures/cocoapods-frameworks.sh').read) + StackFrameworkPackaging.patch_embed_script(path) + signer = Pathname.new(@tmp) / 'fake-codesign' + args = Pathname.new(@tmp) / 'signer-args' + reached = Pathname.new(@tmp) / 'reached' + signer.write("#!/bin/sh\nprintf '%s\\n' \"$@\" > #{Shellwords.escape(args.to_s)}\nexit 42\n") + signer.chmod(0755) + path.write(path.read.sub('/usr/bin/codesign', signer.to_s) + + "\ncode_sign_if_enabled '#{@root}/Other.framework'\ntouch '#{reached}'\n") + env = { + 'FRAMEWORKS_FOLDER_PATH' => 'Frameworks', 'CONFIGURATION_BUILD_DIR' => @tmp, + 'TOOLCHAIN_DIR' => @tmp, 'PLATFORM_NAME' => 'macosx', 'CONFIGURATION' => 'Test', + 'EXPANDED_CODE_SIGN_IDENTITY' => 'configured-identity', + 'EXPANDED_CODE_SIGN_IDENTITY_NAME' => 'Configured Identity', + 'CODE_SIGNING_REQUIRED' => 'YES', 'CODE_SIGNING_ALLOWED' => 'YES', + 'COCOAPODS_PARALLEL_CODE_SIGN' => 'true', + } + _out, _err, status = Open3.capture3(env, '/bin/bash', path.to_s) + assert_equal 42, status.exitstatus + refute reached.exist? + assert_equal ['--force', '--sign', 'configured-identity', '--preserve-metadata=identifier,entitlements', "#{@root}/Other.framework"], args.read.lines.map(&:chomp) + args.unlink + [ + { 'CODE_SIGNING_ALLOWED' => 'NO' }, + { 'CODE_SIGNING_REQUIRED' => 'NO' }, + { 'EXPANDED_CODE_SIGN_IDENTITY' => '' }, + ].each do |disabled| + _out, _err, status = Open3.capture3(env.merge(disabled), '/bin/bash', path.to_s) + assert status.success? + assert reached.exist? + refute args.exist? + reached.unlink + end + end + + def test_changed_embed_script_fails_without_editing + path = Pathname.new(@tmp) / 'embed.sh' + path.write("#{StackFrameworkPackaging::PARALLEL}\nunknown new copy routine\n") + before = path.read + assert_raises(RuntimeError) { StackFrameworkPackaging.patch_embed_script(path) } + assert_equal before, path.read + end +end diff --git a/test/address_utils_test.dart b/test/address_utils_test.dart index dc1bd24e74..40ef880221 100644 --- a/test/address_utils_test.dart +++ b/test/address_utils_test.dart @@ -1,8 +1,85 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:stackwallet/utilities/address_utils.dart'; +import 'package:stackwallet/utilities/logger.dart'; import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +class _RecordingLog extends Fake implements Logging { + final entries = []; + @override + void d( + dynamic message, { + DateTime? time, + Object? error, + StackTrace? stackTrace, + }) { + entries.add('$message $error'); + } + + @override + void i( + dynamic message, { + DateTime? time, + Object? error, + StackTrace? stackTrace, + }) { + entries.add('$message $error'); + } +} + void main() { + test('malformed seed QR does not expose mnemonic data in logs', () { + final log = _RecordingLog(); + expect( + AddressUtils.decodeQRSeedData('{"mnemonic":["secret-seed"', logging: log), + isEmpty, + ); + expect(log.entries.single, 'QR seed data parsing failed FormatException'); + }); + + test('Monero payment URIs cannot erase hidden alias characters', () { + for (final character in [ + '\ufeff', + '\u00a0', + '\u2003', + '\r', + '\n', + '\v', + '\f', + '\x7f', + ]) { + expect( + AddressUtils.parsePaymentUri('monero:${character}alice.example'), + isNull, + ); + expect( + AddressUtils.parsePaymentUri('monero:alice.example$character'), + isNull, + ); + } + }); + test('pasted and typed aliases do not enter payment URI logs', () { + final log = _RecordingLog(); + for (final input in [ + 'alice.example', + 'Alice@Example', + '\u00a0alice.example', + ]) { + expect(AddressUtils.parsePaymentUri(input, logging: log), isNull); + } + expect(log.entries, isEmpty); + }); + test('invalid payment URI logs do not expose recipient data', () { + final log = _RecordingLog(); + expect( + AddressUtils.parsePaymentUri( + 'monero:alice.example?amount=private-note', + logging: log, + ), + isNull, + ); + expect(log.entries.single, 'Invalid payment URI FormatException'); + }); + const String firoAddress = "a6ESWKz7szru5syLtYAPRhHLdKvMq3Yt1j"; test("condense address", () { 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..1698ecde36 --- /dev/null +++ b/test/pages/send_view/open_alias_preview_test.dart @@ -0,0 +1,559 @@ +import 'dart:async'; + +import 'package:doh_resolver/doh_resolver.dart'; +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:platform/platform.dart' as platform; +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'; +import '../../services/openalias/open_alias_test_fixtures.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 = []; + final cancelled = []; + @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)); + } + + @override + Future cancelSend({required TxData txData}) async { + cancelled.add(txData); + } +} + +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; + bool _useTor = false; + @override + bool get useTor => _useTor; + void changeTor(bool value) { + _useTor = value; + notifyListeners(); + } + + @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 prefs = _Prefs(); + 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 previousPlatform = Util.layoutPlatform; + final previousWidth = Util.screenWidth; + final previousIsIpad = Util.isIpad; + Util.layoutPlatform = platform.FakePlatform( + operatingSystem: desktop ? 'macos' : 'android', + ); + Util.isIpad = false; + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + Util.screenWidth = size.width; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(() { + Util.layoutPlatform = previousPlatform; + Util.screenWidth = previousWidth; + Util.isIpad = previousIsIpad; + }); + 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, + trustedValidators: testValidators, + 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( + authenticatedTxt('alice.example', [ + 'oa1:xmr recipient_address=$destination;', + ]), + ); +} + +void main() { + for (final desktop in [false, true]) { + final layout = desktop ? 'desktop 1200x900' : 'mobile 390x844'; + testWidgets('$layout rejects aliases with hidden boundary characters', ( + tester, + ) async { + final h = _Harness(desktop); + await h.mount(tester); + await h.setAmount(tester); + for (final value in ['\u00a0Alice@Example', 'Alice@Example\ufeff']) { + await h.enter(tester, value); + expect(h.enabled(tester), isFalse); + expect(h.calls, 0); + } + }); + 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', + 'privacy away and back', + '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 'privacy away and back': + h.prefs.changeTor(true); + h.prefs.changeTor(false); + 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'); + // A late prepared transaction is released rather than discarded. + expect(h.wallet.cancelled, hasLength(fail ? 0 : 1)); + 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/dnssec_native_test.dart b/test/services/openalias/dnssec_native_test.dart new file mode 100644 index 0000000000..ef76e95ccc --- /dev/null +++ b/test/services/openalias/dnssec_native_test.dart @@ -0,0 +1,35 @@ +import 'package:dnssec_resolver/dnssec_resolver.dart'; +import 'package:doh_resolver/doh_resolver.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test( + 'Flutter loads the native validator and dispatches its DNS query', + () async { + final transport = _Transport(); + await expectLater( + DnssecResolver(transport: transport).lookupTxt('alice.example'), + throwsA( + isA().having((e) => e.code, 'code', DnsError.transport), + ), + ); + expect(transport.calls, 1); + }, + ); +} + +class _Transport implements DohWireTransport { + int calls = 0; + @override + DnsOperation startWire( + Uri uri, + List query, { + required int maxResponseBytes, + }) { + calls++; + throw const DnsException( + DnsError.transport, + 'Expected test transport failure', + ); + } +} 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..854fcc157b --- /dev/null +++ b/test/services/openalias/open_alias_live_test.dart @@ -0,0 +1,63 @@ +import 'dart:io'; + +import 'package:dnssec_resolver/dnssec_resolver.dart'; +import 'package:doh_resolver/doh_resolver_io.dart'; +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('native DNSSEC and Monero address validation run in Flutter', () async { + final transport = IoDohTransport(); + addTearDown(transport.close); + final dns = await DnssecResolver( + transport: transport, + ).lookupTxt('donate.getmonero.org'); + expect(dns.authentication, DnsAuthentication.locallyValidated); + final record = dns.records + .map((r) => r.text) + .firstWhere((r) => r.startsWith('oa1:xmr ')); + final address = RegExp(r'recipient_address=([^;]+)') + .firstMatch(record)![1]!; + expect(validate(address), isTrue); + }, skip: skip); + 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, + ); + test('live nonexistent name is reported with the alias', () async { + await expectLater( + service.resolve('dan@cypherstack', validateAddress: validate), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'No OpenAlias record exists for dan.cypherstack.', + ), + ), + ); + }, 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_packages_test.dart b/test/services/openalias/open_alias_packages_test.dart new file mode 100644 index 0000000000..8a23b7f9a5 --- /dev/null +++ b/test/services/openalias/open_alias_packages_test.dart @@ -0,0 +1,359 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:doh_resolver/doh_resolver.dart'; +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 'open_alias_test_fixtures.dart'; + +void main() { + test('privacy revocation blocks subsequent DNSSEC key requests', () async { + var allowed = true; + final underlying = _CountingTransport(); + final transport = OpenAliasDnsTransport(underlying, () => allowed); + final endpoint = Uri.https('resolver.example', '/dns-query'); + await transport + .startWire(endpoint, List.filled(12, 0), maxResponseBytes: 65535) + .result; + allowed = false; + expect( + () => transport.startWire( + endpoint, + List.filled(12, 0), + maxResponseBytes: 65535, + ), + throwsA( + isA().having((e) => e.code, 'code', DnsError.cancelled), + ), + ); + expect(underlying.calls, 1); + }); + test( + 'remote AD assertion cannot satisfy the wallet local-validation policy', + () async { + final remote = DnsResponse.fromJson( + { + 'Status': 0, + 'AD': true, + 'CD': false, + 'TC': false, + 'Question': [ + {'name': 'alice.example', 'type': 16}, + ], + 'Answer': [ + { + 'name': 'alice.example', + 'type': 16, + 'TTL': 60, + 'data': 'oa1:xmr recipient_address=valid;', + }, + ], + }, + resolver: Uri.https('resolver.example', '/resolve'), + ).authenticatedTxt('alice.example'); + final resolver = OpenAliasService( + externalCalls: () => true, + useTor: () => false, + lookup: (_, _) async => remote, + ); + await expectLater( + resolver.resolve('alice.example', validateAddress: (_) => true), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Local DNSSEC'), + ), + ), + ); + }, + ); + OpenAliasService service(List records) => OpenAliasService( + externalCalls: () => true, + useTor: () => false, + lookup: (name, _) async => authenticatedTxt(name, records), + trustedValidators: testValidators, + ); + for (final records in [ + ['oa1:btc recipient_address=valid;'], + ['oa1:xmr recipient_address=wrong-network;'], + ['oa1:xmr recipient_address=valid;tx_payment_id=123;'], + ['oa1:xmr recipient_address=valid;', 'oa1:xmr recipient_address=second;'], + ['oa1:xmr recipient_address=valid;recipient_address=second;'], + ]) { + test( + 'package migration retains wallet rejection policy: $records', + () async { + await expectLater( + service(records).resolve( + 'alice.example', + validateAddress: (s) => s == 'valid' || s == 'second', + ), + throwsA(isA()), + ); + }, + ); + } + test('payment ID aliases keep the wallet message', () async { + await expectLater( + service(['oa1:xmr recipient_address=valid;tx_payment_id=123;']) + .resolve('alice.example', validateAddress: (_) => true), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('separate payment ID'), + ), + ), + ); + }); + test('only the on-device validator is trusted by default', () async { + final resolver = OpenAliasService( + externalCalls: () => true, + useTor: () => false, + lookup: (name, _) async => + authenticatedTxt(name, ['oa1:xmr recipient_address=valid;']), + ); + await expectLater( + resolver.resolve('alice.example', validateAddress: (_) => true), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Local DNSSEC'), + ), + ), + ); + }); + test('DNS metadata does not replace the literal recipient', () async { + final result = await service([ + 'oa1:xmr recipient_address=valid;tx_amount=999;recipient_name=Someone;', + ]).resolve('Alice@Example.', validateAddress: (s) => s == 'valid'); + expect(result.address, 'valid'); + expect(result.domain, 'alice.example'); + expect(result.dns!.authentication, DnsAuthentication.locallyValidated); + expect(result.displayAlias, 'alice.example'); + }); + test('quotes inside an unquoted recipient name are literal', () async { + final result = await service([ + 'oa1:xmr recipient_address=valid; recipient_name=Alice "Vendor" Smith;', + ]).resolve('alice.example', validateAddress: (s) => s == 'valid'); + expect(result.address, 'valid'); + expect(result.record!.fields['recipient_name'], 'Alice "Vendor" Smith'); + }); + test( + 'parses quoted values with semicolons and skips other prefixes', + () async { + final result = await service([ + 'oa1:btc recipient_address=bad;', + 'oa1:xmrfoo recipient_address=bad;', + 'oa1:xmr recipient_name="Alice; Example";recipient_address="valid";', + ]).resolve('alice.example', validateAddress: (s) => s == 'valid'); + expect(result.address, 'valid'); + expect(result.record!.fields['recipient_name'], 'Alice; Example'); + }, + ); + test('missing records name the alias', () async { + await expectLater( + service(['oa1:btc recipient_address=valid;']) + .resolve('Alice@Example', validateAddress: (_) => true), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'No Monero OpenAlias record was found for alice.example.', + ), + ), + ); + }); + test('a nonexistent name is reported apart from a failed query', () async { + for (final (code, message) in [ + (DnsError.noRecords, 'No OpenAlias record exists for alice.example.'), + ( + DnsError.queryFailed, + 'The DNS lookup for alice.example failed. No address was accepted.', + ), + ]) { + final resolver = OpenAliasService( + externalCalls: () => true, + useTor: () => false, + lookup: (_, _) async => throw DnsException(code, 'failed'), + ); + await expectLater( + resolver.resolve('alice@example', validateAddress: (_) => true), + throwsA( + isA().having( + (e) => e.message, + 'message', + message, + ), + ), + ); + } + }); + test('revoked external calls reject an in-flight result', () async { + var allowed = true; + final pending = Completer(); + final resolver = OpenAliasService( + externalCalls: () => allowed, + useTor: () => false, + lookup: (_, _) => pending.future, + ); + final future = resolver.resolve( + 'alice.example', + validateAddress: (_) => true, + ); + allowed = false; + pending.complete( + authenticatedTxt('alice.example', ['oa1:xmr recipient_address=valid;']), + ); + await expectLater( + future, + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Privacy settings changed'), + ), + ), + ); + }); + test( + 'package authentication failure is translated into a visible UI failure', + () async { + final resolver = OpenAliasService( + externalCalls: () => true, + useTor: () => false, + lookup: (_, _) async => throw const DnsException( + DnsError.authenticationUnavailable, + 'untrusted', + ), + ); + await expectLater( + resolver.resolve('alice.example', validateAddress: (_) => true), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('DNSSEC'), + ), + ), + ); + }, + ); + test('cancelled lookup closes its pending Tor connection', () async { + final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(server.close); + final connected = Completer(); + final disconnected = Completer(); + server.listen((socket) { + addTearDown(socket.destroy); + socket.listen( + (_) { + if (!connected.isCompleted) connected.complete(); + // Leave the SOCKS handshake pending until the lookup is cancelled. + }, + onDone: () { + if (!disconnected.isCompleted) disconnected.complete(); + }, + onError: (Object _) { + if (!disconnected.isCompleted) disconnected.complete(); + }, + ); + }); + final service = OpenAliasService( + externalCalls: () => true, + useTor: () => true, + supportsTor: () => true, + torProxy: () => (host: InternetAddress.loopbackIPv4, port: server.port), + ); + final operation = service.startResolve( + 'alice.example', + validateAddress: (_) => true, + ); + final rejected = expectLater( + operation.result, + throwsA(isA()), + ); + await connected.future.timeout(const Duration(seconds: 5)); + operation.cancel(); + await rejected; + await disconnected.future.timeout(const Duration(seconds: 5)); + }); + + test('each Tor lookup uses its own SOCKS isolation credentials', () async { + final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(server.close); + final usernames = []; + server.listen((socket) { + final buffer = []; + var greeted = false; + socket.listen((data) { + buffer.addAll(data); + if (!greeted) { + if (buffer.length < 2 || buffer.length < 2 + buffer[1]) return; + final methods = buffer.sublist(2, 2 + buffer[1]); + buffer.removeRange(0, 2 + buffer[1]); + greeted = true; + if (!methods.contains(2)) { + socket.destroy(); + return; + } + socket.add([5, 2]); + } + if (buffer.length < 2 || buffer.length < 3 + buffer[1]) return; + final userLength = buffer[1]; + if (buffer.length < 3 + userLength + buffer[2 + userLength]) return; + usernames.add(String.fromCharCodes(buffer.sublist(2, 2 + userLength))); + socket.add([1, 1]); + socket.destroy(); + }, onError: (_) {}); + }); + final resolver = OpenAliasService( + externalCalls: () => true, + useTor: () => true, + supportsTor: () => true, + torProxy: () => (host: InternetAddress.loopbackIPv4, port: server.port), + ); + for (var i = 1; i <= 2; i++) { + await expectLater( + resolver.resolve('alice.example', validateAddress: (_) => true), + throwsA(isA()), + ); + expect(usernames.toSet(), hasLength(i)); + } + }); + test('unavailable Tor produces no direct lookup fallback', () async { + var calls = 0; + final resolver = OpenAliasService( + externalCalls: () => true, + useTor: () => true, + supportsTor: () => true, + lookup: (_, tor) async { + calls++; + expect(tor, isTrue); + throw StateError('Tor unavailable'); + }, + ); + await expectLater( + resolver.resolve('alice.example', validateAddress: (_) => true), + throwsA(isA()), + ); + expect(calls, 1); + }); +} + +class _CountingTransport implements DohWireTransport { + int calls = 0; + @override + DnsOperation startWire( + Uri uri, + List query, { + required int maxResponseBytes, + }) { + calls++; + return DnsOperation(Future.value(DohHttpResponse(200, [])), () {}); + } +} 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..9b2549ea43 --- /dev/null +++ b/test/services/openalias/open_alias_service_test.dart @@ -0,0 +1,137 @@ +import 'dart:async'; + +import 'package:doh_resolver/doh_resolver.dart'; +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 'open_alias_test_fixtures.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 authenticatedTxt('alice.example', []); + }, + ); + 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 => authenticatedTxt('alice.example', []), + ); + 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( + authenticatedTxt('alice.example', ['oa1:xmr recipient_address=valid;']), + ); + await expectLater(future, throwsA(isA())); + }, + ); + test( + 'cancellation settles promptly and ignores a late successful lookup', + () async { + final pending = Completer(); + final service = OpenAliasService( + externalCalls: () => true, + useTor: () => false, + trustedValidators: testValidators, + lookup: (_, _) => pending.future, + ); + final operation = service.startResolve( + 'alice.example', + validateAddress: (_) => true, + ); + final result = expectLater( + operation.result, + throwsA(isA()), + ); + operation.cancel(); + operation.cancel(); + await result; + pending.complete( + authenticatedTxt('alice.example', ['oa1:xmr recipient_address=valid;']), + ); + await Future.delayed(Duration.zero); + }, + ); + + test( + 'caller mutations cannot extend the wallet validator allowlist', + () async { + final trusted = []; + final service = OpenAliasService( + externalCalls: () => true, + useTor: () => false, + trustedValidators: trusted, + lookup: (_, _) async => authenticatedTxt('alice.example', [ + 'oa1:xmr recipient_address=valid;', + ]), + ); + trusted.addAll(testValidators); + await expectLater( + service.resolve('alice.example', validateAddress: (_) => true), + throwsA(isA()), + ); + }, + ); + + test('returns only a validated literal recipient', () async { + final service = OpenAliasService( + externalCalls: () => true, + useTor: () => false, + supportsTor: () => true, + lookup: (_, _) async => authenticatedTxt('alice.example', [ + 'oa1:xmr recipient_address=valid;', + ]), + trustedValidators: testValidators, + ); + 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..6b39ba36a3 --- /dev/null +++ b/test/services/openalias/open_alias_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/services/openalias/open_alias.dart'; + +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('displays aliases as entered, without case or root dot', () { + expect(displayOpenAlias(' Dan@CypherStack.com. '), 'dan@cypherstack.com'); + const recipient = OpenAliasRecipient(domain: 'a.example', address: 'x'); + expect(recipient.displayAlias, 'a.example'); + }); + 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.localhost', + 'a.org\u0000', + ]) { + test('rejects invalid alias $name', () { + expect( + () => normalizeOpenAlias(name), + throwsA(isA()), + ); + }); + } +} diff --git a/test/services/openalias/open_alias_test_fixtures.dart b/test/services/openalias/open_alias_test_fixtures.dart new file mode 100644 index 0000000000..ed6c383cd3 --- /dev/null +++ b/test/services/openalias/open_alias_test_fixtures.dart @@ -0,0 +1,19 @@ +import 'dart:convert'; + +import 'package:doh_resolver/doh_resolver.dart'; + +final _issuer = DnsValidationIssuer('stack-test'); + +final testValidators = [_issuer.validator]; + +AuthenticatedTxtResult authenticatedTxt(String domain, List records) => + _issuer.issueTxt( + question: domain, + canonicalName: domain, + records: records + .map( + (s) => DnsTxtRecord(name: domain, ttl: 60, bytes: utf8.encode(s)), + ) + .toList(), + resolver: Uri.parse('https://resolver.example/dns-query'), + ); 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..effde3207b --- /dev/null +++ b/test/services/openalias/send_preview_test.dart @@ -0,0 +1,198 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openalias/openalias.dart' as oa; +import 'package:stackwallet/services/openalias/open_alias.dart'; +import 'package:stackwallet/services/openalias/send_preview.dart'; + +import 'open_alias_test_fixtures.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('confirmation preserves the validated DNS provenance', () async { + final dns = authenticatedTxt('alice.example', [ + 'oa1:xmr recipient_address=literal;', + ]); + final resolved = await oa.OpenAliasResolver( + lookup: (_) async => dns, + application: 'xmr', + validateAddress: (address) => address == 'literal', + trustedValidators: testValidators, + ).resolve('alice.example'); + await run( + pending: Future.value( + OpenAliasRecipient( + domain: 'alice.example', + address: 'literal', + resolved: resolved, + ), + ), + ); + expect(prepared.single.alias!.dns, same(dns)); + }); + + 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..938e1fce8f --- /dev/null +++ b/test/services/openalias/send_recipient_test.dart @@ -0,0 +1,101 @@ +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('paste and QR cleanup preserve raw aliases for validation', () { + for (final input in [ + '\u00a0alice.example', + 'alice.example\ufeff', + 'alice.example\nignored', + ]) { + final prepared = prepareSendRecipientInput( + input, + supportsOpenAlias: true, + validateAddress: (value) => value == 'literal-address', + ); + expect(prepared, input); + expect(classify(prepared).kind, SendRecipientKind.invalid); + } + expect( + prepareSendRecipientInput( + ' literal-address\nignored ', + supportsOpenAlias: true, + validateAddress: (value) => value == 'literal-address', + ), + 'literal-address', + ); + }); + + test('pasted payment URIs retain whitespace and first-line cleanup', () { + const uri = 'monero:literal-address?tx_amount=1'; + for (final input in [' $uri ', '$uri\nignored']) { + expect( + prepareSendRecipientInput( + input, + supportsOpenAlias: true, + validateAddress: (_) => false, + ), + uri, + ); + } + }); + + 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', + '\u00a0alice.example', + 'alice.example\ufeff', + '\u2003alice.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/services/tor_shutdown_test.dart b/test/services/tor_shutdown_test.dart new file mode 100644 index 0000000000..6701708998 --- /dev/null +++ b/test/services/tor_shutdown_test.dart @@ -0,0 +1,48 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/services/event_bus/events/global/tor_connection_status_changed_event.dart'; +import 'package:stackwallet/wl_gen/generated/tor_service_impl.dart'; +import 'package:tor_ffi_plugin/tor_ffi_plugin.dart'; + +class _Tor extends Fake implements Tor { + final disabled = Completer(); + var stopCalls = 0; + + @override + Future start({required String torDataDirPath}) async {} + + @override + Future disable() => disabled.future; + + @override + Future stop() async => stopCalls++; +} + +void main() { + test( + 'disable waits for native teardown before reporting disconnected', + () async { + final tor = _Tor(); + final service = torService; + // The generated implementation exposes the existing test injection hook. + (service as dynamic).init( + torDataDirPath: 'unused', + mockableOverride: tor, + ); + await service.start(); + expect(service.status, TorConnectionStatus.connected); + + var completed = false; + final disabling = service.disable().then((_) => completed = true); + await Future.delayed(Duration.zero); + expect(completed, isFalse); + expect(service.status, TorConnectionStatus.connected); + + tor.disabled.complete(); + await disabling; + expect(service.status, TorConnectionStatus.disconnected); + expect(tor.stopCalls, 0, reason: 'disable() owns native teardown'); + }, + ); +} diff --git a/test/utilities/test_monero_node_connection_test.dart b/test/utilities/test_monero_node_connection_test.dart new file mode 100644 index 0000000000..e4f93086f7 --- /dev/null +++ b/test/utilities/test_monero_node_connection_test.dart @@ -0,0 +1,88 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/utilities/test_monero_node_connection.dart'; + +/// A SOCKS5 proxy that hands each tunnel to [onTunnel] after the CONNECT reply. +Future _proxy( + Future Function(Socket peer, Stream> request) onTunnel, +) async { + final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(server.close); + server.listen((peer) { + addTearDown(peer.destroy); + final buffer = []; + final request = StreamController>(); + var tunnel = false; + peer.listen( + (bytes) { + if (tunnel) { + request.add(bytes); + return; + } + buffer.addAll(bytes); + if (buffer.length == 3) { + peer.add([5, 0]); + } else if (buffer.length > 8 && buffer.length == 10 + buffer[7]) { + tunnel = true; + peer.add([5, 0, 0, 1, 0, 0, 0, 0, 0, 0]); + unawaited(onTunnel(peer, request.stream)); + } + }, + onError: (Object _) {}, + onDone: request.close, + ); + }); + return server; +} + +Future _check(ServerSocket proxy) => + testMoneroNodeConnection( + Uri.parse("http://node.onion:18081/json_rpc"), + null, + null, + false, + proxyInfo: (host: InternetAddress.loopbackIPv4, port: proxy.port), + ); + +void main() { + test("onion check reads a body sent after the headers", () async { + const body = '{"jsonrpc":"2.0","id":"0","result":{"status":"OK"}}'; + final proxy = await _proxy((peer, request) async { + await request.first; + peer.add( + utf8.encode( + "HTTP/1.1 200 Ok\r\n" + "Content-Type: application/json\r\n" + "Content-Length: ${body.length}\r\n\r\n", + ), + ); + await peer.flush(); + await Future.delayed(const Duration(milliseconds: 50)); + peer.add(utf8.encode(body)); + await peer.flush(); + }); + + expect((await _check(proxy)).success, isTrue); + }); + + test("onion check reports a reset connection as a failure", () async { + final proxy = await _proxy((peer, request) async { + await peer.flush(); + // Linux SO_LINGER {on=1, seconds=0} requests a TCP reset on close. + peer.setRawOption( + RawSocketOption( + 1, + 13, + Uint8List.view(Int32List.fromList([1, 0]).buffer), + ), + ); + peer.destroy(); + }); + + expect((await _check(proxy)).success, isFalse); + }, testOn: "linux"); +} diff --git a/tool/wl_templates/TOR_tor_service_impl.template.dart b/tool/wl_templates/TOR_tor_service_impl.template.dart index 63ee6ef0c5..819836bf9d 100644 --- a/tool/wl_templates/TOR_tor_service_impl.template.dart +++ b/tool/wl_templates/TOR_tor_service_impl.template.dart @@ -115,8 +115,7 @@ class _TorServiceImpl extends TorService { return; } - _tor!.disable(); - await _tor?.stop(); + await _tor!.disable(); _updateStatusAndFireEvent( status: TorConnectionStatus.disconnected,