diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index e26f8c96c..54216675b 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -769,6 +769,25 @@ class _ConfirmTransactionViewState children: [ Text("Send $unit", style: STextStyles.pageTitleH1(context)), const SizedBox(height: 12), + if (widget.txData.openAlias != null) ...[ + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "OpenAlias", + style: STextStyles.smallMed12(context), + ), + const SizedBox(height: 4), + Text( + widget.txData.openAlias!, + style: STextStyles.itemSubtitle12(context), + ), + ], + ), + ), + const SizedBox(height: 12), + ], RoundedWhiteContainer( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -1125,6 +1144,41 @@ class _ConfirmTransactionViewState .extension()! .background, ), + if (widget.txData.openAlias != null) ...[ + 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.openAlias!, + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of(context) + .extension()! + .textDark, + ), + ), + ], + ), + ), + Container( + height: 1, + color: Theme.of(context) + .extension()! + .background, + ), + ], Padding( padding: const EdgeInsets.all(12), child: Column( diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 98a38c147..ec11f2c05 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -11,6 +11,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:cs_openalias/cs_openalias.dart'; import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -30,6 +31,7 @@ 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_service.dart'; import '../../services/spark_names_service.dart'; import '../../themes/coin_icon_provider.dart'; import '../../themes/stack_colors.dart'; @@ -703,8 +705,9 @@ class _SendViewState extends ConsumerState { } } - ref.read(pValidSendToAddress.notifier).state = wallet.cryptoCurrency - .validateAddress(address ?? ""); + ref.read(pValidSendToAddress.notifier).state = + wallet.cryptoCurrency.validateAddress(address ?? "") || + (coin is Monero && isOpenAlias(address ?? "")); } } @@ -1018,11 +1021,69 @@ class _SendViewState extends ConsumerState { } } + /// Replaces the OpenAlias in the address field with the address it resolves + /// to. Returns the alias, trimmed and lowercased, or null if the lookup + /// failed or was cancelled. + Future _resolveOpenAlias() async { + final alias = _address!.trim().toLowerCase(); + CsOpenAliasOperation? operation; + bool cancelled = false; + unawaited( + showDialog( + context: context, + useSafeArea: false, + barrierDismissible: false, + builder: (context) => BuildingTransactionDialog( + coin: coin, + isSpark: false, + onCancel: () { + cancelled = true; + operation?.cancel(); + Navigator.of(context).pop(); + }, + ), + ), + ); + try { + operation = ref + .read(pOpenAliasService) + .lookup(_address!, isValidAddress: coin.validateAddress); + final address = (await operation.result).address; + if (cancelled || !mounted) return null; + Navigator.of(context, rootNavigator: true).pop(); + sendToController.text = address; + _address = address; + _setValidAddressProviders(_address); + return alias; + } catch (e, s) { + if (cancelled || !mounted) return null; + Logging.instance.w("OpenAlias lookup failed", error: e, stackTrace: s); + Navigator.of(context, rootNavigator: true).pop(); + unawaited( + showDialog( + context: context, + builder: (context) => StackOkDialog( + title: openAliasLookupFailedTitle, + message: e is CsOpenAliasException ? e.userMessage : e.toString(), + ), + ), + ); + return null; + } + } + Future _previewTransaction() async { // wait for keyboard to disappear FocusScope.of(context).unfocus(); await Future.delayed(const Duration(milliseconds: 100)); final wallet = ref.read(pWallets).getWallet(walletId); + String? openAlias; + if (coin is Monero && + !coin.validateAddress(_address ?? "") && + isOpenAlias(_address ?? "")) { + openAlias = await _resolveOpenAlias(); + if (openAlias == null || !mounted) return; + } final Amount amount = ref.read(pSendAmount)!; final Amount availableBalance; @@ -1274,6 +1335,7 @@ class _SendViewState extends ConsumerState { )!, ), ], + openAlias: openAlias, memo: memo, feeRateType: feeRateType, satsPerVByte: satsPerVByte, 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 e2dd502ba..8a71b9b65 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 @@ -10,6 +10,7 @@ import 'dart:async'; +import 'package:cs_openalias/cs_openalias.dart'; import 'package:decimal/decimal.dart'; import 'package:dropdown_button2/dropdown_button2.dart'; import 'package:flutter/material.dart'; @@ -37,6 +38,7 @@ 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_service.dart'; import '../../../../services/spark_names_service.dart'; import '../../../../themes/stack_colors.dart'; import '../../../../utilities/address_utils.dart'; @@ -73,6 +75,7 @@ import '../../../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../../../widgets/desktop/primary_button.dart'; import '../../../../widgets/desktop/qr_code_scanner_dialog.dart'; import '../../../../widgets/desktop/secondary_button.dart'; +import '../../../../widgets/desktop/simple_desktop_dialog.dart'; import '../../../../widgets/dialogs/firo_exchange_address_dialog.dart'; import '../../../../widgets/epic_txs_method_toggle.dart'; import '../../../../widgets/eth_fee_form.dart'; @@ -437,12 +440,77 @@ class _DesktopSendState extends ConsumerState { } } + /// Replaces the OpenAlias in the address field with the address it resolves + /// to. Returns the alias, trimmed and lowercased, or null if the lookup + /// failed or was cancelled. + Future _resolveOpenAlias() async { + final alias = _address!.trim().toLowerCase(); + CsOpenAliasOperation? operation; + bool cancelled = false; + unawaited( + showDialog( + context: context, + useSafeArea: false, + barrierDismissible: false, + builder: (context) => DesktopDialog( + maxWidth: 400, + maxHeight: double.infinity, + child: Padding( + padding: const EdgeInsets.all(32), + child: BuildingTransactionDialog( + coin: coin, + isSpark: false, + onCancel: () { + cancelled = true; + operation?.cancel(); + Navigator.of(context).pop(); + }, + ), + ), + ), + ), + ); + try { + operation = ref + .read(pOpenAliasService) + .lookup(_address!, isValidAddress: coin.validateAddress); + final address = (await operation.result).address; + if (cancelled || !mounted) return null; + Navigator.of(context, rootNavigator: true).pop(); + sendToController.text = address; + _address = address; + _setValidAddressProviders(_address); + return alias; + } catch (e, s) { + if (cancelled || !mounted) return null; + Logging.instance.w("OpenAlias lookup failed", error: e, stackTrace: s); + Navigator.of(context, rootNavigator: true).pop(); + unawaited( + showDialog( + context: context, + builder: (context) => SimpleDesktopDialog( + title: openAliasLookupFailedTitle, + message: e is CsOpenAliasException ? e.userMessage : e.toString(), + ), + ), + ); + return null; + } + } + Future previewSend() async { final nonceInput = _nonceInput; if (!nonceInput.isValid) return; final nonce = nonceInput.value; final wallet = ref.read(pWallets).getWallet(walletId); + String? openAlias; + if (coin is Monero && + !coin.validateAddress(_address ?? "") && + isOpenAlias(_address ?? "")) { + openAlias = await _resolveOpenAlias(); + if (openAlias == null || !mounted) return; + } // Handle MWC slatepack transactions directly. if (isMimblewimblecoin && ref.read(pIsSlatepack(widget.walletId))) { @@ -743,6 +811,7 @@ class _DesktopSendState extends ConsumerState { )!, ), ], + openAlias: openAlias, memo: memo, feeRateType: feeRateType, satsPerVByte: satsPerVByte, @@ -1021,8 +1090,9 @@ class _DesktopSendState extends ConsumerState { } } - ref.read(pValidSendToAddress.notifier).state = wallet.cryptoCurrency - .validateAddress(address ?? ""); + ref.read(pValidSendToAddress.notifier).state = + wallet.cryptoCurrency.validateAddress(address ?? "") || + (coin is Monero && isOpenAlias(address ?? "")); } } diff --git a/lib/services/openalias/open_alias_service.dart b/lib/services/openalias/open_alias_service.dart new file mode 100644 index 000000000..74196c13b --- /dev/null +++ b/lib/services/openalias/open_alias_service.dart @@ -0,0 +1,106 @@ +import 'dart:io'; + +import 'package:cs_openalias/cs_openalias.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../app_config.dart'; +import '../../utilities/prefs.dart'; +import '../tor_service.dart'; + +final pOpenAliasService = Provider((_) => OpenAliasService()); + +/// Whether [input] has the form of an OpenAlias name. +bool isOpenAlias(String input) { + try { + normalizeOpenAlias(input); + return true; + } on CsOpenAliasInvalidAliasException { + return false; + } +} + +typedef OpenAliasLookup = CsOpenAliasOperation Function( + String alias, { + required bool Function(String address) isValidAddress, + CsOpenAliasProxy? proxy, +}); + +/// Thrown by [OpenAliasService.lookup] when no lookup may be sent. +final class OpenAliasUnavailableException implements Exception { + const OpenAliasUnavailableException(this.message); + + final String message; + + @override + String toString() => message; +} + +/// Starts OpenAlias lookups that follow the Incognito and Tor settings. +class OpenAliasService { + OpenAliasService({ + bool Function()? externalCalls, + bool Function()? useTor, + bool Function()? supportsTor, + ({InternetAddress host, int port}) Function()? torProxy, + OpenAliasLookup? lookup, + }) : _externalCalls = externalCalls ?? (() => Prefs.instance.externalCalls), + _useTor = useTor ?? (() => Prefs.instance.useTor), + _supportsTor = + supportsTor ?? (() => AppConfig.hasFeature(AppFeature.tor)), + _torProxy = torProxy ?? (() => TorService.sharedInstance.getProxyInfo()), + _lookup = lookup ?? resolveOpenAlias; + + final bool Function() _externalCalls; + final bool Function() _useTor; + final bool Function() _supportsTor; + final ({InternetAddress host, int port}) Function() _torProxy; + final OpenAliasLookup _lookup; + + /// Why no lookup can be sent now, or null when one can. + String? unavailableReason() => _route().unavailable; + + /// Looks up [alias], through Tor when Tor is on. Throws + /// [OpenAliasUnavailableException] without sending anything when Incognito + /// mode is on or Tor is on but unavailable. + CsOpenAliasOperation lookup( + String alias, { + required bool Function(String address) isValidAddress, + }) { + final route = _route(); + final unavailable = route.unavailable; + if (unavailable != null) { + throw OpenAliasUnavailableException(unavailable); + } + return _lookup(alias, isValidAddress: isValidAddress, proxy: route.proxy); + } + + ({String? unavailable, CsOpenAliasProxy? proxy}) _route() { + if (!_externalCalls()) { + return ( + unavailable: + 'OpenAlias lookups are off in Incognito mode. To use OpenAlias, ' + 'switch ${AppConfig.prefix} Experience to Easy Crypto in Advanced ' + 'settings.', + proxy: null, + ); + } + if (!_useTor()) { + return (unavailable: null, proxy: null); + } + if (_supportsTor()) { + try { + final info = _torProxy(); + return ( + unavailable: null, + proxy: CsOpenAliasProxy(address: info.host.address, port: info.port), + ); + } catch (_) { + // Tor is not connected; never fall back to a direct lookup. + } + } + return ( + unavailable: 'Tor is unavailable. No lookup was sent.', + proxy: null, + ); + } +} diff --git a/lib/wallets/models/tx_data.dart b/lib/wallets/models/tx_data.dart index 16d6673f0..b6519b513 100644 --- a/lib/wallets/models/tx_data.dart +++ b/lib/wallets/models/tx_data.dart @@ -57,6 +57,11 @@ class TxData { final String? memo; final List? recipients; + + /// The OpenAlias, trimmed and lowercased, that the recipient's address was + /// resolved from. + final String? openAlias; + final Set? utxos; final List? usedUTXOs; @@ -137,6 +142,7 @@ class TxData { this.noteOnChain, this.memo, this.recipients, + this.openAlias, this.utxos, this.usedUTXOs, this.changeAddress, @@ -287,6 +293,7 @@ class TxData { Set? utxos, List? usedUTXOs, List? recipients, + String? openAlias, String? frostMSConfig, List? frostSigners, String? changeAddress, @@ -341,6 +348,7 @@ class TxData { utxos: utxos ?? this.utxos, usedUTXOs: usedUTXOs ?? this.usedUTXOs, recipients: recipients ?? this.recipients, + openAlias: openAlias ?? this.openAlias, frostMSConfig: frostMSConfig ?? this.frostMSConfig, frostSigners: frostSigners ?? this.frostSigners, changeAddress: changeAddress ?? this.changeAddress, diff --git a/pubspec.lock b/pubspec.lock index 1b3b95320..a603b297d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -516,6 +516,15 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + cs_openalias: + dependency: "direct main" + description: + path: "." + ref: d0755ff124d3f384ba1895800f5155af0280617b + resolved-ref: d0755ff124d3f384ba1895800f5155af0280617b + url: "https://github.com/cypherstack/cs_openalias.git" + source: git + version: "0.0.1" cs_salvium: dependency: "direct main" description: @@ -829,6 +838,24 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + dnssec_resolver: + dependency: "direct overridden" + description: + path: "." + ref: e03de21083a819732efef323c62c1bc259fe17f5 + resolved-ref: e03de21083a819732efef323c62c1bc259fe17f5 + url: "https://github.com/cypherstack/dnssec_resolver.git" + source: git + version: "0.0.1" + doh_transport: + dependency: "direct overridden" + description: + path: "." + ref: a6a5d8e8843c045ae4f18bee9e34ffde5d457910 + resolved-ref: a6a5d8e8843c045ae4f18bee9e34ffde5d457910 + url: "https://github.com/cypherstack/doh_transport.git" + source: git + version: "0.0.1" drift: dependency: "direct main" description: @@ -889,11 +916,11 @@ packages: dependency: "direct main" description: path: "." - ref: b6fa44d015d3bfa06934b73219928c29ca48a290 - resolved-ref: b6fa44d015d3bfa06934b73219928c29ca48a290 + ref: "76ffad6ecc1e31de47d9187fd39c831db0aa987f" + 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 +1259,8 @@ packages: dependency: "direct main" description: path: "." - ref: "14427bcbbe1e754bce4a1b93cdb0a31ce56d792b" - resolved-ref: "14427bcbbe1e754bce4a1b93cdb0a31ce56d792b" + ref: "7548bf1c6e79a82e991c3feb61c1908dfe324275" + resolved-ref: "7548bf1c6e79a82e991c3feb61c1908dfe324275" url: "https://github.com/cypherstack/fusiondart.git" source: git version: "1.0.0" @@ -1750,6 +1777,15 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.0" + openalias: + dependency: "direct overridden" + description: + path: "." + ref: fbcb56b9f399d61b52cc762412c538343e460923 + resolved-ref: fbcb56b9f399d61b52cc762412c538343e460923 + url: "https://github.com/cypherstack/openalias.git" + source: git + version: "0.0.1" package_config: dependency: transitive description: @@ -2140,13 +2176,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: diff --git a/scripts/app_config/templates/pubspec.template.yaml b/scripts/app_config/templates/pubspec.template.yaml index 60e89c7b7..f02f65504 100644 --- a/scripts/app_config/templates/pubspec.template.yaml +++ b/scripts/app_config/templates/pubspec.template.yaml @@ -117,10 +117,15 @@ dependencies: fusiondart: git: url: https://github.com/cypherstack/fusiondart.git - ref: 14427bcbbe1e754bce4a1b93cdb0a31ce56d792b + ref: 7548bf1c6e79a82e991c3feb61c1908dfe324275 # Utility plugins http: ^1.6.0 + cs_openalias: + git: + url: https://github.com/cypherstack/cs_openalias.git + ref: d0755ff124d3f384ba1895800f5155af0280617b + socks_socket: ^2.0.0 local_auth: ^2.3.0 permission_handler: ^13.0.1 flutter_local_notifications: ^22.3.0 @@ -229,7 +234,7 @@ dependencies: electrum_adapter: git: url: https://github.com/cypherstack/electrum_adapter.git - ref: b6fa44d015d3bfa06934b73219928c29ca48a290 + ref: 76ffad6ecc1e31de47d9187fd39c831db0aa987f stream_channel: ^2.1.0 solana: git: # TODO [prio=low]: Revert to official package once Tor support is merged upstream. @@ -308,6 +313,20 @@ flutter_native_splash: color_dark: "2A2D34" dependency_overrides: + # cs_openalias's constituent packages are not published yet. + openalias: + git: + url: https://github.com/cypherstack/openalias.git + ref: fbcb56b9f399d61b52cc762412c538343e460923 + dnssec_resolver: + git: + url: https://github.com/cypherstack/dnssec_resolver.git + ref: e03de21083a819732efef323c62c1bc259fe17f5 + doh_transport: + git: + url: https://github.com/cypherstack/doh_transport.git + ref: a6a5d8e8843c045ae4f18bee9e34ffde5d457910 + # 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 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 000000000..b835aebd2 --- /dev/null +++ b/test/pages/send_view/open_alias_preview_test.dart @@ -0,0 +1,532 @@ +import 'dart:async'; + +import 'package:cs_openalias/cs_openalias.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:platform/platform.dart' as platform; +import 'package:stackwallet/models/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/models/send_view_auto_fill_data.dart'; +import 'package:stackwallet/pages/send_view/confirm_transaction_view.dart'; +import 'package:stackwallet/pages/send_view/frost_ms/recipient.dart'; +import 'package:stackwallet/pages/send_view/send_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/crypto_currency/intermediate/cryptonote_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 'package:stackwallet/widgets/custom_buttons/blue_text_button.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; + +import '../../sample_data/theme_json.dart'; + +const literal = + '4AeRgkWZsMJhAWKMeCZ3h4ZSPnAcW5VBtRFyLd6gBEf6GgJU2FH' + 'XDA6i1DnQTd6h8R3VU5AkbGcWSNhtSwNNPgaD48gp4nn'; +const alias = 'Alice@Example.com'; +const balance = 10000000000000; + +Amount amount(int value) => + Amount(rawValue: BigInt.from(value), fractionDigits: 12); + +bool _isValid(String address) => + address == literal || address == 'second-recipient'; + +AddressType _addressType(String address) { + if (!_isValid(address)) throw StateError('Unresolved recipient: $address'); + return AddressType.cryptonote; +} + +class _Monero extends Monero { + _Monero() : super(CryptoCurrencyNetwork.main); + @override + bool validateAddress(String address) => _isValid(address); + @override + AddressType? getAddressType(String address) => _addressType(address); +} + +class _Wownero extends Wownero { + _Wownero() : super(CryptoCurrencyNetwork.main); + @override + bool validateAddress(String address) => _isValid(address); + @override + AddressType? getAddressType(String address) => _addressType(address); +} + +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(balance), + spendable: amount(balance), + blockedTotal: amount(0), + pendingSpendable: amount(0), + ); +} + +class _Wallet extends Fake implements CryptonoteWallet { + _Wallet(this.cryptoCurrency) : info = _Info(cryptoCurrency); + @override + final CryptonoteCurrency cryptoCurrency; + @override + final WalletInfo info; + final prepared = []; + @override + bool get supportsMultiRecipient => true; + @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 txData.copyWith(fee: amount(10)); + } + + @override + Future cancelSend({required TxData txData}) async {} +} + +class _Wallets extends Fake implements Wallets { + _Wallets(this.wallet); + final Wallet wallet; + @override + Wallet getWallet(String walletId) => wallet; +} + +class _Prefs extends ChangeNotifier implements Prefs { + @override + String get currency => 'USD'; + @override + bool get externalCalls => false; + @override + bool get enableCoinControl => false; + @override + AmountUnit amountUnit(CryptoCurrency coin) => AmountUnit.normal; + // Short balances keep the mobile balance row within 390 px in the test font. + @override + int maxDecimals(CryptoCurrency coin) => 2; + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Lookup { + _Lookup(this.alias, this.isValidAddress); + final String alias; + final bool Function(String) isValidAddress; + final result = Completer(); + int cancels = 0; +} + +class _Harness { + _Harness(this.desktop, {CryptonoteCurrency? coin, this.incognito = false}) + : coin = coin ?? _Monero(); + + final bool desktop; + final CryptonoteCurrency coin; + final bool incognito; + late final _Wallet wallet = _Wallet(coin); + late final ProviderContainer container; + final navigator = GlobalKey(); + final lookups = <_Lookup>[]; + + late final service = OpenAliasService( + externalCalls: () => !incognito, + useTor: () => false, + lookup: (alias, {required isValidAddress, proxy}) { + final lookup = _Lookup(alias, isValidAddress); + lookups.add(lookup); + return CsOpenAliasOperation(lookup.result.future, () { + lookup.cancels++; + if (!lookup.result.isCompleted) { + lookup.result.completeError( + CsOpenAliasCancelledException('cancelled', alias: alias), + ); + } + }); + }, + ); + + 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)), + pWalletInfo('wallet').overrideWithValue(wallet.info), + pWalletCoin('wallet').overrideWithValue(coin), + pWalletName('wallet').overrideWithValue('Test wallet'), + pWalletBalance('wallet').overrideWithValue(wallet.info.cachedBalance), + prefsChangeNotifierProvider.overrideWithValue(_Prefs()), + coinIconProvider(coin) + .overrideWithValue('test/sample_data/light/assets/dummy.svg'), + coinImageSecondaryProvider(coin).overrideWithValue('dummy.svg'), + pOpenAliasService.overrideWithValue(service), + ], + ); + addTearDown(() async { + await tester.pumpWidget(const SizedBox()); + await tester.pumpAndSettle(); + container.dispose(); + }); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + navigatorKey: navigator, + theme: ThemeData( + extensions: [StackColors.fromStackColorTheme(theme)], + ), + home: const Scaffold(body: Text('Home')), + ), + ), + ); + unawaited( + navigator.currentState!.push( + MaterialPageRoute( + builder: (_) => desktop + ? Scaffold( + body: SingleChildScrollView( + child: DesktopSend( + walletId: 'wallet', + autoFillData: autofill, + ), + ), + ) + : SendView( + walletId: 'wallet', + 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'); + Finder get ok => find.text(desktop ? 'Ok' : 'OK'); + TxData confirmed(WidgetTester tester) => tester + .widget(find.byType(ConfirmTransactionView)) + .txData; + // The send screen is offstage under the mobile confirm screen. + String text(WidgetTester tester) => tester + .widget( + find.byKey(const Key('sendViewAddressFieldKey'), skipOffstage: false), + ) + .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, [ + int value = 1000000000000, + ]) async { + container.read(pSendAmount.notifier).state = amount(value); + await tester.pumpAndSettle(); + } + + /// Taps Preview and stops while the lookup is pending; the progress dialog + /// animates, so this never settles. + Future preview(WidgetTester tester) async { + await tester.ensureVisible(button); + 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() => lookups.single.result.complete( + CsOpenAliasRecipient( + domain: 'alice.example.com', + address: literal, + application: 'xmr', + canonicalName: 'alice.example.com', + ttl: 300, + fields: const {'recipient_address': literal}, + ), + ); +} + +void main() { + for (final desktop in [false, true]) { + final layout = desktop ? 'desktop 1200x900' : 'mobile 390x844'; + + testWidgets('$layout an alias in the Monero field enables Preview', ( + tester, + ) async { + final h = _Harness(desktop); + await h.mount(tester); + await h.setAmount(tester); + await h.enter(tester, 'not an alias'); + expect(find.text('Invalid address'), findsOneWidget); + expect(h.enabled(tester), isFalse); + await h.enter(tester, alias); + expect(find.text('Invalid address'), findsNothing); + expect(h.enabled(tester), isTrue); + expect(h.lookups, isEmpty); + }); + + testWidgets('$layout an alias in the Wownero field stays invalid', ( + tester, + ) async { + final h = _Harness(desktop, coin: _Wownero()); + await h.mount(tester); + await h.setAmount(tester); + await h.enter(tester, alias); + expect(find.text('Invalid address'), findsOneWidget); + expect(h.enabled(tester), isFalse); + }); + + testWidgets('$layout Preview resolves the alias and confirms the address', ( + tester, + ) async { + final h = _Harness(desktop); + await h.mount(tester); + await h.enter(tester, alias); + await h.setAmount(tester); + await h.preview(tester); + final lookup = h.lookups.single; + expect(lookup.alias, alias); + expect(lookup.isValidAddress(literal), isTrue); + expect(lookup.isValidAddress(alias), isFalse); + expect(find.text('Cancel'), findsOneWidget); + expect(h.wallet.prepared, isEmpty); + + h.succeed(); + await h.finish(tester); + expect(h.wallet.prepared.single.recipients!.single.address, literal); + expect(find.byType(ConfirmTransactionView), findsOneWidget); + expect(h.confirmed(tester).recipients!.single.address, literal); + expect(h.confirmed(tester).openAlias, 'alice@example.com'); + expect(find.text('OpenAlias'), findsOneWidget); + expect(find.text('alice@example.com'), findsOneWidget); + expect(find.text(literal), findsWidgets); + expect(h.text(tester), literal); + }); + + testWidgets('$layout the lookup runs before the send-all confirmation', ( + tester, + ) async { + final h = _Harness(desktop); + await h.mount(tester); + await h.enter(tester, alias); + await h.setAmount(tester, balance); + await h.preview(tester); + expect(h.lookups, hasLength(1)); + expect(find.text('Confirm send all'), findsNothing); + h.succeed(); + await tester.pumpAndSettle(); + expect(find.text('Confirm send all'), findsOneWidget); + expect(h.wallet.prepared, isEmpty); + expect(h.text(tester), literal); + }); + + testWidgets('$layout Cancel during the lookup cancels it', (tester) async { + final h = _Harness(desktop); + await h.mount(tester); + await h.enter(tester, alias); + await h.setAmount(tester); + await h.preview(tester); + await tester.tap(find.text('Cancel')); + await h.finish(tester); + expect(h.lookups.single.cancels, 1); + expect(h.wallet.prepared, isEmpty); + expect(find.byType(ConfirmTransactionView), findsNothing); + expect(find.text('Generating transaction'), findsNothing); + expect(find.text(openAliasLookupFailedTitle), findsNothing); + expect(h.text(tester), alias); + expect(h.enabled(tester), isTrue); + }); + + testWidgets('$layout a lookup failure shows the OpenAlias error', ( + tester, + ) async { + final h = _Harness(desktop); + await h.mount(tester); + await h.enter(tester, alias); + await h.setAmount(tester, balance); + await h.preview(tester); + const error = CsOpenAliasNoRecordException( + 'no oa1:xmr record', + alias: 'alice.example.com', + ); + h.lookups.single.result.completeError(error); + await h.finish(tester); + expect(find.text('OpenAlias lookup failed'), findsOneWidget); + expect(find.text(error.userMessage), findsOneWidget); + expect(find.text('Generating transaction'), findsNothing); + expect(find.text('Confirm send all'), findsNothing); + expect(find.text('Transaction failed'), findsNothing); + expect(h.wallet.prepared, isEmpty); + await tester.tap(h.ok); + await tester.pumpAndSettle(); + expect(find.byType(ConfirmTransactionView), findsNothing); + expect(h.text(tester), alias); + }); + + testWidgets('$layout Incognito shows the reason and sends nothing', ( + tester, + ) async { + final h = _Harness(desktop, incognito: true); + await h.mount(tester); + await h.enter(tester, alias); + await h.setAmount(tester); + expect(h.enabled(tester), isTrue); + await h.preview(tester); + await h.finish(tester); + expect(find.text('OpenAlias lookup failed'), findsOneWidget); + expect(find.text(h.service.unavailableReason()!), findsOneWidget); + expect(h.lookups, isEmpty); + expect(h.wallet.prepared, isEmpty); + }); + + for (final input in ['literal', 'contact']) { + testWidgets('$layout a $input address never starts a lookup', ( + tester, + ) async { + final h = _Harness(desktop); + await h.mount( + tester, + autofill: input == 'contact' + ? SendViewAutoFillData(address: literal, contactLabel: alias) + : null, + ); + if (input == 'literal') await h.enter(tester, literal); + await h.setAmount(tester); + expect(h.enabled(tester), isTrue); + await h.preview(tester); + await h.finish(tester); + expect(h.lookups, isEmpty); + expect(h.wallet.prepared.single.recipients!.single.address, literal); + expect(find.byType(ConfirmTransactionView), findsOneWidget); + expect(h.confirmed(tester).openAlias, isNull); + expect(find.text('OpenAlias'), findsNothing); + }); + } + + testWidgets('$layout multiple recipients never start a lookup', ( + tester, + ) async { + final h = _Harness(desktop); + await h.mount(tester); + await h.enter(tester, alias); + await h.setAmount(tester); + await tester.tap( + find.byWidgetPredicate( + (widget) => + widget is CustomTextButton && widget.text == 'Add recipient', + ), + ); + await tester.pumpAndSettle(); + final forms = find.byType(Recipient); + expect(forms, findsNWidgets(2)); + Finder inForms(Key key) => + find.descendant(of: forms, matching: find.byKey(key)); + final addresses = inForms(const Key('sendViewAddressFieldKey')); + await tester.enterText(addresses.first, literal); + await tester.enterText(addresses.last, 'second-recipient'); + await tester.enterText( + inForms(const Key('amountInputFieldCryptoTextFieldKey')).last, + '2', + ); + await tester.pumpAndSettle(); + expect(h.enabled(tester), isTrue); + await h.preview(tester); + await h.finish(tester); + expect(h.lookups, isEmpty); + expect(h.wallet.prepared.single.recipients!.map((e) => e.address), [ + literal, + 'second-recipient', + ]); + expect(find.byType(ConfirmTransactionView), findsOneWidget); + expect(h.confirmed(tester).openAlias, isNull); + }); + } +} diff --git a/test/services/openalias/cs_openalias_native_test.dart b/test/services/openalias/cs_openalias_native_test.dart new file mode 100644 index 000000000..b3ff0c29c --- /dev/null +++ b/test/services/openalias/cs_openalias_native_test.dart @@ -0,0 +1,35 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:cs_openalias/cs_openalias.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/services/openalias/open_alias_service.dart'; + +void main() { + test( + 'Flutter loads the facade native validator and queries through Tor', + () async { + // A local stand-in for Tor: the first connection proves the native + // validator started and sent a query; the lookup is then cancelled. + final proxy = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(proxy.close); + final connected = Completer(); + proxy.listen((socket) { + if (!connected.isCompleted) connected.complete(); + socket.destroy(); + }); + final operation = OpenAliasService( + externalCalls: () => true, + useTor: () => true, + supportsTor: () => true, + torProxy: () => (host: InternetAddress.loopbackIPv4, port: proxy.port), + ).lookup('alice.example', isValidAddress: (_) => true); + await connected.future.timeout(const Duration(seconds: 10)); + operation.cancel(); + await expectLater( + operation.result, + throwsA(isA()), + ); + }, + ); +} 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 000000000..cf58d2c2b --- /dev/null +++ b/test/services/openalias/open_alias_live_test.dart @@ -0,0 +1,49 @@ +import 'dart:io'; + +import 'package:cs_openalias/cs_openalias.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/services/openalias/open_alias_service.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; + +void main() { + final skip = Platform.environment['OPENALIAS_LIVE_TESTS'] != '1'; + final service = OpenAliasService( + externalCalls: () => true, + useTor: () => false, + ); + final validate = Monero(CryptoCurrencyNetwork.main).validateAddress; + + test( + 'live signed alias uses native DNSSEC and Monero address validation', + () async { + final result = await service + .lookup('donate.getmonero.org', isValidAddress: validate) + .result; + 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.lookup('dan@cypherstack', isValidAddress: validate).result, + throwsA( + isA().having( + (error) => error.userMessage, + 'userMessage', + 'No Monero OpenAlias record was found 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.lookup(domain, isValidAddress: validate).result, + throwsA(isA()), + ); + }, skip: skip); + } +} diff --git a/test/services/openalias/open_alias_service_test.dart b/test/services/openalias/open_alias_service_test.dart new file mode 100644 index 000000000..d46e4a374 --- /dev/null +++ b/test/services/openalias/open_alias_service_test.dart @@ -0,0 +1,157 @@ +import 'dart:io'; + +import 'package:cs_openalias/cs_openalias.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/services/openalias/open_alias_service.dart'; + +const incognito = + 'OpenAlias lookups are off in Incognito mode. To use OpenAlias, switch ' + 'Stack Experience to Easy Crypto in Advanced settings.'; +const torUnavailable = 'Tor is unavailable. No lookup was sent.'; + +class _Calls { + final aliases = []; + final proxies = []; + final validators = []; + final operations = >[]; + + CsOpenAliasOperation lookup( + String alias, { + required bool Function(String address) isValidAddress, + CsOpenAliasProxy? proxy, + }) { + aliases.add(alias); + proxies.add(proxy); + validators.add(isValidAddress); + final operation = CsOpenAliasOperation( + Future.error(CsOpenAliasNoRecordException('no record', alias: alias)), + () {}, + ); + operations.add(operation); + return operation; + } +} + +void main() { + test('isOpenAlias accepts only names that normalizeOpenAlias accepts', () { + for (final input in ['Alice@Example.com', 'donate.getmonero.org.']) { + expect(isOpenAlias(input), isTrue, reason: input); + } + for (final input in [ + '', + 'alice', + 'a@b@example.com', + 'alice example.com', + '4AeRgkWZsMJhAWKMeCZ3h4ZSPnAcW5VBtRFyLd6gBEf6GgJU2FH', + ]) { + expect(isOpenAlias(input), isFalse, reason: input); + } + }); + + test('Incognito mode sends nothing and names the setting', () { + final calls = _Calls(); + final service = OpenAliasService( + externalCalls: () => false, + useTor: () => false, + lookup: calls.lookup, + ); + expect(service.unavailableReason(), incognito); + expect( + () => service.lookup('alice@example.com', isValidAddress: (_) => true), + throwsA( + isA().having( + (e) => e.message, + 'message', + incognito, + ), + ), + ); + expect(calls.aliases, isEmpty); + }); + + test('Tor that is unsupported or not connected has no direct fallback', () { + final calls = _Calls(); + for (final (supported, torProxy) + in <(bool, ({InternetAddress host, int port}) Function())>[ + (false, () => (host: InternetAddress.loopbackIPv4, port: 9050)), + (true, () => throw Exception('Tor is not connected')), + ]) { + final service = OpenAliasService( + externalCalls: () => true, + useTor: () => true, + supportsTor: () => supported, + torProxy: torProxy, + lookup: calls.lookup, + ); + expect(service.unavailableReason(), torUnavailable); + expect( + () => service.lookup('alice@example.com', isValidAddress: (_) => true), + throwsA( + isA().having( + (e) => e.message, + 'message', + torUnavailable, + ), + ), + ); + } + expect(calls.aliases, isEmpty); + }); + + test('the Tor proxy is passed only when Tor is on', () { + for (final tor in [true, false]) { + final calls = _Calls(); + final service = OpenAliasService( + externalCalls: () => true, + useTor: () => tor, + supportsTor: () => true, + torProxy: () => (host: InternetAddress.loopbackIPv4, port: 9050), + lookup: calls.lookup, + ); + expect(service.unavailableReason(), isNull); + service.lookup('alice@example.com', isValidAddress: (_) => true); + expect(calls.proxies.single?.address, tor ? '127.0.0.1' : null); + expect(calls.proxies.single?.port, tor ? 9050 : null); + } + }); + + test('the facade operation and arguments pass through unchanged', () { + final calls = _Calls(); + final service = OpenAliasService( + externalCalls: () => true, + useTor: () => false, + lookup: calls.lookup, + ); + bool validate(String address) => address == 'valid'; + final operation = service.lookup( + ' Alice@Example.com', + isValidAddress: validate, + ); + expect(calls.aliases.single, ' Alice@Example.com'); + expect(calls.validators.single, same(validate)); + expect(operation, same(calls.operations.single)); + }); + + test('the settings are read once for every lookup', () { + final calls = _Calls(); + var reads = 0; + var external = true; + final service = OpenAliasService( + externalCalls: () { + reads++; + return external; + }, + useTor: () => false, + lookup: calls.lookup, + ); + service.lookup('alice@example.com', isValidAddress: (_) => true); + expect(reads, 1); + external = false; + expect( + () => service.lookup('alice@example.com', isValidAddress: (_) => true), + throwsA(isA()), + ); + expect(reads, 2); + expect(calls.aliases, hasLength(1)); + }); +} diff --git a/test/wallets/tx_data_test.dart b/test/wallets/tx_data_test.dart index b07748a04..62b4dd447 100644 --- a/test/wallets/tx_data_test.dart +++ b/test/wallets/tx_data_test.dart @@ -15,4 +15,17 @@ void main() { false, ); }); + + test('openAlias defaults to null and copies', () { + expect(TxData().openAlias, isNull); + expect(TxData().copyWith().openAlias, isNull); + + final txData = TxData(openAlias: 'donate@getmonero.org'); + expect(txData.openAlias, 'donate@getmonero.org'); + expect(txData.copyWith(note: 'note').openAlias, 'donate@getmonero.org'); + expect( + TxData().copyWith(openAlias: 'alice@example.com').openAlias, + 'alice@example.com', + ); + }); }