From 6ad44e98de86cb4eff16fb84b32ee23470055a6b Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 16 Oct 2026 17:23:55 -0500 Subject: [PATCH 1/8] build(openalias): add the cs_openalias dependency Add cs_openalias, pinned by commit, and override its unpublished constituents openalias, dnssec_resolver and doh_transport with pinned commits too. doh_transport needs socks_socket ^2.0.0, so socks_socket becomes a direct dependency at 2.0.0, and fusiondart and electrum_adapter move to the revisions that accept it (electrum_adapter 3.0.2 -> 4.0.1, the merge of cypherstack/electrum_adapter#15). The lock was resolved from local checkouts of the pinned commits and carries the GitHub URLs, so it resolves without local paths once those commits are on GitHub. --- pubspec.lock | 52 ++++++++++++++++--- .../templates/pubspec.template.yaml | 23 +++++++- 2 files changed, 65 insertions(+), 10 deletions(-) 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 From 29d0563aea0b4edd1e33a1cafd5ef476934365af Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 16 Oct 2026 17:41:09 -0500 Subject: [PATCH 2/8] feat(openalias): resolve Monero OpenAlias recipients when previewing a send On Monero, an address field holding a name that has the form of an OpenAlias counts as a valid recipient, so Preview is enabled and no "Invalid address" is shown. Previewing such a send first looks the alias up through cs_openalias behind the usual progress dialog, whose Cancel cancels the lookup. The resolved address replaces the alias in the field as a paste would, and the preview continues as for any literal address. A failed or unavailable lookup shows the OpenAlias error and stops before anything else runs. OpenAliasService reads the Incognito and Tor settings for each lookup. With Incognito mode on, or Tor on but unavailable, nothing is sent and the error says why; otherwise it returns the facade's operation, routed through the Tor proxy when Tor is on. --- lib/pages/send_view/send_view.dart | 61 +- .../wallet_view/sub_widgets/desktop_send.dart | 69 ++- .../openalias/open_alias_service.dart | 106 ++++ .../send_view/open_alias_preview_test.dart | 522 ++++++++++++++++++ .../openalias/cs_openalias_native_test.dart | 35 ++ .../openalias/open_alias_live_test.dart | 49 ++ .../openalias/open_alias_service_test.dart | 157 ++++++ 7 files changed, 995 insertions(+), 4 deletions(-) create mode 100644 lib/services/openalias/open_alias_service.dart create mode 100644 test/pages/send_view/open_alias_preview_test.dart create mode 100644 test/services/openalias/cs_openalias_native_test.dart create mode 100644 test/services/openalias/open_alias_live_test.dart create mode 100644 test/services/openalias/open_alias_service_test.dart diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 98a38c147..027cc6944 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,65 @@ class _SendViewState extends ConsumerState { } } + /// Replaces the OpenAlias in the address field with the address it resolves + /// to. Returns false if the lookup failed or was cancelled. + Future _resolveOpenAlias() async { + 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 false; + Navigator.of(context, rootNavigator: true).pop(); + sendToController.text = address; + _address = address; + _setValidAddressProviders(_address); + return true; + } catch (e, s) { + if (cancelled || !mounted) return false; + 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 false; + } + } + 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); + if (coin is Monero && + !coin.validateAddress(_address ?? "") && + isOpenAlias(_address ?? "")) { + if (!await _resolveOpenAlias() || !mounted) return; + } final Amount amount = ref.read(pSendAmount)!; final Amount availableBalance; 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..5f1729085 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,73 @@ class _DesktopSendState extends ConsumerState { } } + /// Replaces the OpenAlias in the address field with the address it resolves + /// to. Returns false if the lookup failed or was cancelled. + Future _resolveOpenAlias() async { + 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 false; + Navigator.of(context, rootNavigator: true).pop(); + sendToController.text = address; + _address = address; + _setValidAddressProviders(_address); + return true; + } catch (e, s) { + if (cancelled || !mounted) return false; + 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 false; + } + } + Future previewSend() async { final nonceInput = _nonceInput; if (!nonceInput.isValid) return; final nonce = nonceInput.value; final wallet = ref.read(pWallets).getWallet(walletId); + if (coin is Monero && + !coin.validateAddress(_address ?? "") && + isOpenAlias(_address ?? "")) { + if (!await _resolveOpenAlias() || !mounted) return; + } // Handle MWC slatepack transactions directly. if (isMimblewimblecoin && ref.read(pIsSlatepack(widget.walletId))) { @@ -1021,8 +1085,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/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..110f6a4d6 --- /dev/null +++ b/test/pages/send_view/open_alias_preview_test.dart @@ -0,0 +1,522 @@ +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'); + // 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(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); + }); + } + + 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); + }); + } +} 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)); + }); +} From b2da8badd58725ced422685c765c57c4a061946e Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 16 Oct 2026 18:10:11 -0500 Subject: [PATCH 3/8] feat(tx): carry the OpenAlias a recipient was resolved from TxData gains an optional openAlias: the alias, trimmed and lowercased, that the recipient's address was resolved from. copyWith keeps it. --- lib/wallets/models/tx_data.dart | 8 ++++++++ test/wallets/tx_data_test.dart | 13 +++++++++++++ 2 files changed, 21 insertions(+) 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/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', + ); + }); } From ba9f73fbd9e39d107f1a3ea02c214bf96724c408 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 16 Oct 2026 18:11:10 -0500 Subject: [PATCH 4/8] feat(openalias): attach the resolved alias to the prepared transaction The preview functions keep the alias they resolved in a local and pass it as TxData.openAlias with the single recipient, so it is set only for the send that resolved it. Other coins, literal addresses and multiple recipients leave it null. --- lib/pages/send_view/send_view.dart | 19 ++++++++++++------- .../wallet_view/sub_widgets/desktop_send.dart | 19 ++++++++++++------- .../send_view/open_alias_preview_test.dart | 7 +++++++ 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 027cc6944..ec11f2c05 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -1022,8 +1022,10 @@ class _SendViewState extends ConsumerState { } /// Replaces the OpenAlias in the address field with the address it resolves - /// to. Returns false if the lookup failed or was cancelled. - Future _resolveOpenAlias() async { + /// 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( @@ -1047,14 +1049,14 @@ class _SendViewState extends ConsumerState { .read(pOpenAliasService) .lookup(_address!, isValidAddress: coin.validateAddress); final address = (await operation.result).address; - if (cancelled || !mounted) return false; + if (cancelled || !mounted) return null; Navigator.of(context, rootNavigator: true).pop(); sendToController.text = address; _address = address; _setValidAddressProviders(_address); - return true; + return alias; } catch (e, s) { - if (cancelled || !mounted) return false; + if (cancelled || !mounted) return null; Logging.instance.w("OpenAlias lookup failed", error: e, stackTrace: s); Navigator.of(context, rootNavigator: true).pop(); unawaited( @@ -1066,7 +1068,7 @@ class _SendViewState extends ConsumerState { ), ), ); - return false; + return null; } } @@ -1075,10 +1077,12 @@ class _SendViewState extends ConsumerState { 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 ?? "")) { - if (!await _resolveOpenAlias() || !mounted) return; + openAlias = await _resolveOpenAlias(); + if (openAlias == null || !mounted) return; } final Amount amount = ref.read(pSendAmount)!; @@ -1331,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 5f1729085..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 @@ -441,8 +441,10 @@ class _DesktopSendState extends ConsumerState { } /// Replaces the OpenAlias in the address field with the address it resolves - /// to. Returns false if the lookup failed or was cancelled. - Future _resolveOpenAlias() async { + /// 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( @@ -473,14 +475,14 @@ class _DesktopSendState extends ConsumerState { .read(pOpenAliasService) .lookup(_address!, isValidAddress: coin.validateAddress); final address = (await operation.result).address; - if (cancelled || !mounted) return false; + if (cancelled || !mounted) return null; Navigator.of(context, rootNavigator: true).pop(); sendToController.text = address; _address = address; _setValidAddressProviders(_address); - return true; + return alias; } catch (e, s) { - if (cancelled || !mounted) return false; + if (cancelled || !mounted) return null; Logging.instance.w("OpenAlias lookup failed", error: e, stackTrace: s); Navigator.of(context, rootNavigator: true).pop(); unawaited( @@ -492,7 +494,7 @@ class _DesktopSendState extends ConsumerState { ), ), ); - return false; + return null; } } @@ -502,10 +504,12 @@ class _DesktopSendState extends ConsumerState { final nonce = nonceInput.value; final wallet = ref.read(pWallets).getWallet(walletId); + String? openAlias; if (coin is Monero && !coin.validateAddress(_address ?? "") && isOpenAlias(_address ?? "")) { - if (!await _resolveOpenAlias() || !mounted) return; + openAlias = await _resolveOpenAlias(); + if (openAlias == null || !mounted) return; } // Handle MWC slatepack transactions directly. @@ -807,6 +811,7 @@ class _DesktopSendState extends ConsumerState { )!, ), ], + openAlias: openAlias, memo: memo, feeRateType: feeRateType, satsPerVByte: satsPerVByte, diff --git a/test/pages/send_view/open_alias_preview_test.dart b/test/pages/send_view/open_alias_preview_test.dart index 110f6a4d6..19e4d7452 100644 --- a/test/pages/send_view/open_alias_preview_test.dart +++ b/test/pages/send_view/open_alias_preview_test.dart @@ -270,6 +270,9 @@ class _Harness { 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( @@ -380,6 +383,8 @@ void main() { 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(literal), findsWidgets); expect(h.text(tester), literal); }); @@ -479,6 +484,7 @@ void main() { expect(h.lookups, isEmpty); expect(h.wallet.prepared.single.recipients!.single.address, literal); expect(find.byType(ConfirmTransactionView), findsOneWidget); + expect(h.confirmed(tester).openAlias, isNull); }); } @@ -517,6 +523,7 @@ void main() { 'second-recipient', ]); expect(find.byType(ConfirmTransactionView), findsOneWidget); + expect(h.confirmed(tester).openAlias, isNull); }); } } From 623d018fd1e55f0ef0bf18669957b42e6855c86a Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 16 Oct 2026 18:12:47 -0500 Subject: [PATCH 5/8] feat(confirm): show the OpenAlias above the recipient address When the transaction carries an OpenAlias, the confirm screen shows it labelled "OpenAlias" above the recipient on mobile and above "Send to" on desktop. --- .../send_view/confirm_transaction_view.dart | 54 +++++++++++++++++++ .../send_view/open_alias_preview_test.dart | 3 ++ 2 files changed, 57 insertions(+) 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/test/pages/send_view/open_alias_preview_test.dart b/test/pages/send_view/open_alias_preview_test.dart index 19e4d7452..b835aebd2 100644 --- a/test/pages/send_view/open_alias_preview_test.dart +++ b/test/pages/send_view/open_alias_preview_test.dart @@ -385,6 +385,8 @@ void main() { 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); }); @@ -485,6 +487,7 @@ void main() { 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); }); } From ca9c852fc7dcf2508e8cc4d4332dba4534ea9bee Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 21 Oct 2026 17:05:07 -0500 Subject: [PATCH 6/8] feat(openalias): own the lookup error texts The cs_openalias pin moves with the code because cs_openalias 0.0.1 dropped `userMessage`, `openAliasLookupFailedTitle` and `normalizeOpenAlias`. Pins cs_openalias 15b980d, openalias 46d8402, dnssec_resolver 8e133d6 and doh_transport 3dbac48. Resolved with `flutter pub get` against ~/.pub-cache git mirrors of github.com/cypherstack/ fetched from the local checkouts; the URLs resolve once those SHAs are on GitHub. --- lib/pages/send_view/send_view.dart | 4 ++- .../wallet_view/sub_widgets/desktop_send.dart | 4 ++- .../openalias/open_alias_service.dart | 26 ++++++++++------- pubspec.lock | 16 +++++------ .../templates/pubspec.template.yaml | 8 +++--- .../send_view/open_alias_preview_test.dart | 12 ++++---- .../openalias/open_alias_live_test.dart | 4 +-- .../openalias/open_alias_service_test.dart | 28 +++++++++++++++++-- 8 files changed, 69 insertions(+), 33 deletions(-) diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index ec11f2c05..c6d6062d5 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -1064,7 +1064,9 @@ class _SendViewState extends ConsumerState { context: context, builder: (context) => StackOkDialog( title: openAliasLookupFailedTitle, - message: e is CsOpenAliasException ? e.userMessage : e.toString(), + message: e is CsOpenAliasException + ? openAliasErrorMessage(e) + : e.toString(), ), ), ); 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 8a71b9b65..71af12aa7 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 @@ -490,7 +490,9 @@ class _DesktopSendState extends ConsumerState { context: context, builder: (context) => SimpleDesktopDialog( title: openAliasLookupFailedTitle, - message: e is CsOpenAliasException ? e.userMessage : e.toString(), + message: e is CsOpenAliasException + ? openAliasErrorMessage(e) + : e.toString(), ), ), ); diff --git a/lib/services/openalias/open_alias_service.dart b/lib/services/openalias/open_alias_service.dart index 74196c13b..75d918a74 100644 --- a/lib/services/openalias/open_alias_service.dart +++ b/lib/services/openalias/open_alias_service.dart @@ -9,20 +9,26 @@ 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; - } -} +const openAliasLookupFailedTitle = 'OpenAlias lookup failed'; + +String openAliasErrorMessage(CsOpenAliasException e) => switch (e) { + CsOpenAliasInvalidAliasException() => 'Enter a valid address or OpenAlias.', + CsOpenAliasNoRecordException() => + 'No Monero OpenAlias record was found for ${e.alias}.', + CsOpenAliasInvalidRecordException() => + 'This alias has an invalid or unsupported Monero record for this wallet.', + CsOpenAliasDnssecException() => + 'DNSSEC verification failed or is unavailable. Ask the recipient for a ' + 'verified alias or address.', + CsOpenAliasNetworkException() => + 'OpenAlias lookup failed. Check your connection and try again.', + CsOpenAliasCancelledException() => 'OpenAlias lookup cancelled.', +}; typedef OpenAliasLookup = CsOpenAliasOperation Function( String alias, { required bool Function(String address) isValidAddress, - CsOpenAliasProxy? proxy, + required CsOpenAliasProxy? proxy, }); /// Thrown by [OpenAliasService.lookup] when no lookup may be sent. diff --git a/pubspec.lock b/pubspec.lock index a603b297d..adf899006 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -520,8 +520,8 @@ packages: dependency: "direct main" description: path: "." - ref: d0755ff124d3f384ba1895800f5155af0280617b - resolved-ref: d0755ff124d3f384ba1895800f5155af0280617b + ref: "15b980d57c8174a131f795543356d5684d2354bc" + resolved-ref: "15b980d57c8174a131f795543356d5684d2354bc" url: "https://github.com/cypherstack/cs_openalias.git" source: git version: "0.0.1" @@ -842,8 +842,8 @@ packages: dependency: "direct overridden" description: path: "." - ref: e03de21083a819732efef323c62c1bc259fe17f5 - resolved-ref: e03de21083a819732efef323c62c1bc259fe17f5 + ref: "8e133d6a60acc5f15ce88fb2d7c9acd603896c55" + resolved-ref: "8e133d6a60acc5f15ce88fb2d7c9acd603896c55" url: "https://github.com/cypherstack/dnssec_resolver.git" source: git version: "0.0.1" @@ -851,8 +851,8 @@ packages: dependency: "direct overridden" description: path: "." - ref: a6a5d8e8843c045ae4f18bee9e34ffde5d457910 - resolved-ref: a6a5d8e8843c045ae4f18bee9e34ffde5d457910 + ref: "3dbac4862762f62cb15a01a064b6861a77bb56ea" + resolved-ref: "3dbac4862762f62cb15a01a064b6861a77bb56ea" url: "https://github.com/cypherstack/doh_transport.git" source: git version: "0.0.1" @@ -1781,8 +1781,8 @@ packages: dependency: "direct overridden" description: path: "." - ref: fbcb56b9f399d61b52cc762412c538343e460923 - resolved-ref: fbcb56b9f399d61b52cc762412c538343e460923 + ref: "46d8402773fe3e85f15e4f0be97b92fddcd0a70e" + resolved-ref: "46d8402773fe3e85f15e4f0be97b92fddcd0a70e" url: "https://github.com/cypherstack/openalias.git" source: git version: "0.0.1" diff --git a/scripts/app_config/templates/pubspec.template.yaml b/scripts/app_config/templates/pubspec.template.yaml index f02f65504..22960d1d5 100644 --- a/scripts/app_config/templates/pubspec.template.yaml +++ b/scripts/app_config/templates/pubspec.template.yaml @@ -124,7 +124,7 @@ dependencies: cs_openalias: git: url: https://github.com/cypherstack/cs_openalias.git - ref: d0755ff124d3f384ba1895800f5155af0280617b + ref: 15b980d57c8174a131f795543356d5684d2354bc socks_socket: ^2.0.0 local_auth: ^2.3.0 permission_handler: ^13.0.1 @@ -317,15 +317,15 @@ dependency_overrides: openalias: git: url: https://github.com/cypherstack/openalias.git - ref: fbcb56b9f399d61b52cc762412c538343e460923 + ref: 46d8402773fe3e85f15e4f0be97b92fddcd0a70e dnssec_resolver: git: url: https://github.com/cypherstack/dnssec_resolver.git - ref: e03de21083a819732efef323c62c1bc259fe17f5 + ref: 8e133d6a60acc5f15ce88fb2d7c9acd603896c55 doh_transport: git: url: https://github.com/cypherstack/doh_transport.git - ref: a6a5d8e8843c045ae4f18bee9e34ffde5d457910 + ref: 3dbac4862762f62cb15a01a064b6861a77bb56ea # 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 diff --git a/test/pages/send_view/open_alias_preview_test.dart b/test/pages/send_view/open_alias_preview_test.dart index b835aebd2..59233a56a 100644 --- a/test/pages/send_view/open_alias_preview_test.dart +++ b/test/pages/send_view/open_alias_preview_test.dart @@ -173,7 +173,7 @@ class _Harness { late final service = OpenAliasService( externalCalls: () => !incognito, useTor: () => false, - lookup: (alias, {required isValidAddress, proxy}) { + lookup: (alias, {required isValidAddress, required proxy}) { final lookup = _Lookup(alias, isValidAddress); lookups.add(lookup); return CsOpenAliasOperation(lookup.result.future, () { @@ -326,9 +326,6 @@ class _Harness { CsOpenAliasRecipient( domain: 'alice.example.com', address: literal, - application: 'xmr', - canonicalName: 'alice.example.com', - ttl: 300, fields: const {'recipient_address': literal}, ), ); @@ -440,7 +437,12 @@ void main() { 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( + 'No Monero OpenAlias record was found for alice.example.com.', + ), + findsOneWidget, + ); expect(find.text('Generating transaction'), findsNothing); expect(find.text('Confirm send all'), findsNothing); expect(find.text('Transaction failed'), findsNothing); diff --git a/test/services/openalias/open_alias_live_test.dart b/test/services/openalias/open_alias_live_test.dart index cf58d2c2b..14322eb4f 100644 --- a/test/services/openalias/open_alias_live_test.dart +++ b/test/services/openalias/open_alias_live_test.dart @@ -30,8 +30,8 @@ void main() { service.lookup('dan@cypherstack', isValidAddress: validate).result, throwsA( isA().having( - (error) => error.userMessage, - 'userMessage', + openAliasErrorMessage, + 'openAliasErrorMessage', 'No Monero OpenAlias record was found for dan.cypherstack.', ), ), diff --git a/test/services/openalias/open_alias_service_test.dart b/test/services/openalias/open_alias_service_test.dart index d46e4a374..bc7ac7bc8 100644 --- a/test/services/openalias/open_alias_service_test.dart +++ b/test/services/openalias/open_alias_service_test.dart @@ -18,7 +18,7 @@ class _Calls { CsOpenAliasOperation lookup( String alias, { required bool Function(String address) isValidAddress, - CsOpenAliasProxy? proxy, + required CsOpenAliasProxy? proxy, }) { aliases.add(alias); proxies.add(proxy); @@ -33,7 +33,7 @@ class _Calls { } void main() { - test('isOpenAlias accepts only names that normalizeOpenAlias accepts', () { + test('isOpenAlias accepts names and rejects addresses', () { for (final input in ['Alice@Example.com', 'donate.getmonero.org.']) { expect(isOpenAlias(input), isTrue, reason: input); } @@ -48,6 +48,30 @@ void main() { } }); + test('every lookup failure has a user message', () { + const alias = 'alice.example.com'; + final messages = { + for (final e in [ + const CsOpenAliasInvalidAliasException('', alias: alias), + const CsOpenAliasNoRecordException('', alias: alias), + const CsOpenAliasInvalidRecordException('', alias: alias), + const CsOpenAliasDnssecException( + '', + alias: alias, + status: CsOpenAliasDnssecStatus.bogus, + ), + const CsOpenAliasNetworkException('', alias: alias), + const CsOpenAliasCancelledException('', alias: alias), + ]) + openAliasErrorMessage(e), + }; + expect(messages, hasLength(6)); + expect( + messages, + contains('No Monero OpenAlias record was found for alice.example.com.'), + ); + }); + test('Incognito mode sends nothing and names the setting', () { final calls = _Calls(); final service = OpenAliasService( From afe2936102bc20a4fab17f66d1bcd9474ac45726 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 21 Oct 2026 17:07:08 -0500 Subject: [PATCH 7/8] fix(openalias): keep the alias on every send path Only the generic branch passed the resolved alias into TxData, so the Firo spark mint, transparent and spark sends and the MWEB send reached the confirm screen without the alias row. Their prepare methods return copies of the TxData they are given, so passing it in is enough. PayNym sends are left alone: their recipient is the payment code, never a typed address, so no alias can reach them. No widget test: the preview harness fakes a Cryptonote wallet and the lookup is still gated on Monero, so covering a Firo or MWEB branch needs a new fake wallet rather than a case. --- lib/pages/send_view/send_view.dart | 4 ++++ .../my_stack_view/wallet_view/sub_widgets/desktop_send.dart | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index c6d6062d5..605642d66 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -1235,6 +1235,7 @@ class _SendViewState extends ConsumerState { isChange: false, ), ], + openAlias: openAlias, feeRateType: feeRateType, satsPerVByte: satsPerVByte, utxos: (coinControlEnabled && selectedUTXOs.isNotEmpty) @@ -1255,6 +1256,7 @@ class _SendViewState extends ConsumerState { )!, ), ], + openAlias: openAlias, feeRateType: feeRateType, satsPerVByte: satsPerVByte, utxos: (coinControlEnabled && selectedUTXOs.isNotEmpty) @@ -1291,6 +1293,7 @@ class _SendViewState extends ConsumerState { ), ] : null, + openAlias: openAlias, ), ); break; @@ -1308,6 +1311,7 @@ class _SendViewState extends ConsumerState { addressType: wallet.cryptoCurrency.getAddressType(_address!)!, ), ], + openAlias: openAlias, 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 71af12aa7..651767413 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 @@ -708,6 +708,7 @@ class _DesktopSendState extends ConsumerState { isChange: false, ), ], + openAlias: openAlias, feeRateType: feeRateType, satsPerVByte: satsPerVByte, utxos: @@ -730,6 +731,7 @@ class _DesktopSendState extends ConsumerState { )!, ), ], + openAlias: openAlias, feeRateType: feeRateType, satsPerVByte: satsPerVByte, utxos: @@ -768,6 +770,7 @@ class _DesktopSendState extends ConsumerState { ), ] : null, + openAlias: openAlias, ), ); break; @@ -785,6 +788,7 @@ class _DesktopSendState extends ConsumerState { addressType: wallet.cryptoCurrency.getAddressType(_address!)!, ), ], + openAlias: openAlias, feeRateType: feeRateType, satsPerVByte: satsPerVByte, // these will need to be mweb utxos From 680c4bb64fb03ee3b93398dcfa3bb9162c9fb6f7 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 21 Oct 2026 17:09:05 -0500 Subject: [PATCH 8/8] feat(openalias): gate OpenAlias on the coin's application tag A coin that supports OpenAlias names the application of its records (`oa1:xmr` for Monero) through `CryptoCurrency.openAliasApplication`, null for every other coin. The send screens use that instead of checking for Monero, pass the tag through the service to the lookup, and name the coin in the lookup error texts. A coin opts in by overriding openAliasApplication; the send views pass the alias through on every path. Monero is the only coin that sets it. Pins cs_openalias 84e3e85, which takes `application`, with openalias 46d8402, dnssec_resolver ea1f7c0 and doh_transport 7211549, resolved the same way as before. openalias is also a dev dependency so the application test can import it. --- lib/pages/send_view/send_view.dart | 17 +++-- .../wallet_view/sub_widgets/desktop_send.dart | 17 +++-- .../openalias/open_alias_service.dart | 44 ++++++----- lib/wallets/crypto_currency/coins/monero.dart | 3 + .../crypto_currency/crypto_currency.dart | 4 + pubspec.lock | 14 ++-- .../templates/pubspec.template.yaml | 10 ++- .../send_view/open_alias_preview_test.dart | 76 +++++++++++++++---- .../openalias/cs_openalias_native_test.dart | 18 +++-- .../openalias/open_alias_live_test.dart | 20 ++++- .../openalias/open_alias_service_test.dart | 37 +++++++-- test/wallets/open_alias_application_test.dart | 25 ++++++ 12 files changed, 216 insertions(+), 69 deletions(-) create mode 100644 test/wallets/open_alias_application_test.dart diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 605642d66..4bfc02e29 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -707,7 +707,7 @@ class _SendViewState extends ConsumerState { ref.read(pValidSendToAddress.notifier).state = wallet.cryptoCurrency.validateAddress(address ?? "") || - (coin is Monero && isOpenAlias(address ?? "")); + (coin.openAliasApplication != null && isOpenAlias(address ?? "")); } } @@ -1024,7 +1024,7 @@ 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 { + Future _resolveOpenAlias(String application) async { final alias = _address!.trim().toLowerCase(); CsOpenAliasOperation? operation; bool cancelled = false; @@ -1047,7 +1047,11 @@ class _SendViewState extends ConsumerState { try { operation = ref .read(pOpenAliasService) - .lookup(_address!, isValidAddress: coin.validateAddress); + .lookup( + _address!, + isValidAddress: coin.validateAddress, + application: application, + ); final address = (await operation.result).address; if (cancelled || !mounted) return null; Navigator.of(context, rootNavigator: true).pop(); @@ -1065,7 +1069,7 @@ class _SendViewState extends ConsumerState { builder: (context) => StackOkDialog( title: openAliasLookupFailedTitle, message: e is CsOpenAliasException - ? openAliasErrorMessage(e) + ? openAliasErrorMessage(e, coin.prettyName) : e.toString(), ), ), @@ -1080,10 +1084,11 @@ class _SendViewState extends ConsumerState { await Future.delayed(const Duration(milliseconds: 100)); final wallet = ref.read(pWallets).getWallet(walletId); String? openAlias; - if (coin is Monero && + final application = coin.openAliasApplication; + if (application != null && !coin.validateAddress(_address ?? "") && isOpenAlias(_address ?? "")) { - openAlias = await _resolveOpenAlias(); + openAlias = await _resolveOpenAlias(application); if (openAlias == null || !mounted) return; } 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 651767413..33206f8a1 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 @@ -443,7 +443,7 @@ 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 { + Future _resolveOpenAlias(String application) async { final alias = _address!.trim().toLowerCase(); CsOpenAliasOperation? operation; bool cancelled = false; @@ -473,7 +473,11 @@ class _DesktopSendState extends ConsumerState { try { operation = ref .read(pOpenAliasService) - .lookup(_address!, isValidAddress: coin.validateAddress); + .lookup( + _address!, + isValidAddress: coin.validateAddress, + application: application, + ); final address = (await operation.result).address; if (cancelled || !mounted) return null; Navigator.of(context, rootNavigator: true).pop(); @@ -491,7 +495,7 @@ class _DesktopSendState extends ConsumerState { builder: (context) => SimpleDesktopDialog( title: openAliasLookupFailedTitle, message: e is CsOpenAliasException - ? openAliasErrorMessage(e) + ? openAliasErrorMessage(e, coin.prettyName) : e.toString(), ), ), @@ -507,10 +511,11 @@ class _DesktopSendState extends ConsumerState { final wallet = ref.read(pWallets).getWallet(walletId); String? openAlias; - if (coin is Monero && + final application = coin.openAliasApplication; + if (application != null && !coin.validateAddress(_address ?? "") && isOpenAlias(_address ?? "")) { - openAlias = await _resolveOpenAlias(); + openAlias = await _resolveOpenAlias(application); if (openAlias == null || !mounted) return; } @@ -1098,7 +1103,7 @@ class _DesktopSendState extends ConsumerState { ref.read(pValidSendToAddress.notifier).state = wallet.cryptoCurrency.validateAddress(address ?? "") || - (coin is Monero && isOpenAlias(address ?? "")); + (coin.openAliasApplication != null && isOpenAlias(address ?? "")); } } diff --git a/lib/services/openalias/open_alias_service.dart b/lib/services/openalias/open_alias_service.dart index 75d918a74..e2fb63e9c 100644 --- a/lib/services/openalias/open_alias_service.dart +++ b/lib/services/openalias/open_alias_service.dart @@ -11,23 +11,27 @@ final pOpenAliasService = Provider((_) => OpenAliasService()); const openAliasLookupFailedTitle = 'OpenAlias lookup failed'; -String openAliasErrorMessage(CsOpenAliasException e) => switch (e) { - CsOpenAliasInvalidAliasException() => 'Enter a valid address or OpenAlias.', - CsOpenAliasNoRecordException() => - 'No Monero OpenAlias record was found for ${e.alias}.', - CsOpenAliasInvalidRecordException() => - 'This alias has an invalid or unsupported Monero record for this wallet.', - CsOpenAliasDnssecException() => - 'DNSSEC verification failed or is unavailable. Ask the recipient for a ' - 'verified alias or address.', - CsOpenAliasNetworkException() => - 'OpenAlias lookup failed. Check your connection and try again.', - CsOpenAliasCancelledException() => 'OpenAlias lookup cancelled.', -}; +String openAliasErrorMessage(CsOpenAliasException e, String coinName) => + switch (e) { + CsOpenAliasInvalidAliasException() => + 'Enter a valid address or OpenAlias.', + CsOpenAliasNoRecordException() => + 'No $coinName OpenAlias record was found for ${e.alias}.', + CsOpenAliasInvalidRecordException() => + 'This alias has an invalid or unsupported $coinName record for this ' + 'wallet.', + CsOpenAliasDnssecException() => + 'DNSSEC verification failed or is unavailable. Ask the recipient for a ' + 'verified alias or address.', + CsOpenAliasNetworkException() => + 'OpenAlias lookup failed. Check your connection and try again.', + CsOpenAliasCancelledException() => 'OpenAlias lookup cancelled.', + }; typedef OpenAliasLookup = CsOpenAliasOperation Function( String alias, { required bool Function(String address) isValidAddress, + required String application, required CsOpenAliasProxy? proxy, }); @@ -65,19 +69,25 @@ class OpenAliasService { /// 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. + /// Looks up the [application] record of [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, + required String application, }) { final route = _route(); final unavailable = route.unavailable; if (unavailable != null) { throw OpenAliasUnavailableException(unavailable); } - return _lookup(alias, isValidAddress: isValidAddress, proxy: route.proxy); + return _lookup( + alias, + isValidAddress: isValidAddress, + application: application, + proxy: route.proxy, + ); } ({String? unavailable, CsOpenAliasProxy? proxy}) _route() { diff --git a/lib/wallets/crypto_currency/coins/monero.dart b/lib/wallets/crypto_currency/coins/monero.dart index 379c47d70..409dd6a2d 100644 --- a/lib/wallets/crypto_currency/coins/monero.dart +++ b/lib/wallets/crypto_currency/coins/monero.dart @@ -45,6 +45,9 @@ class Monero extends CryptonoteCurrency { @override bool get torSupport => true; + @override + String? get openAliasApplication => "xmr"; + @override bool validateAddress(String address) { if (address.contains("111")) { diff --git a/lib/wallets/crypto_currency/crypto_currency.dart b/lib/wallets/crypto_currency/crypto_currency.dart index 16bba3ff7..d46382ea0 100644 --- a/lib/wallets/crypto_currency/crypto_currency.dart +++ b/lib/wallets/crypto_currency/crypto_currency.dart @@ -63,6 +63,10 @@ abstract class CryptoCurrency { // Override in subclass if the currency has Tor support: bool get torSupport => false; + // Override in subclass if the currency supports OpenAlias: the application + // of its records, e.g. "xmr" for `oa1:xmr` records. + String? get openAliasApplication => null; + int get minConfirms; int get minCoinbaseConfirms => minConfirms; int? get mwebPegoutMaturity => null; diff --git a/pubspec.lock b/pubspec.lock index adf899006..a00a0e479 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -520,8 +520,8 @@ packages: dependency: "direct main" description: path: "." - ref: "15b980d57c8174a131f795543356d5684d2354bc" - resolved-ref: "15b980d57c8174a131f795543356d5684d2354bc" + ref: "84e3e85ced4b41e502063bcaa3314c1ec60adce3" + resolved-ref: "84e3e85ced4b41e502063bcaa3314c1ec60adce3" url: "https://github.com/cypherstack/cs_openalias.git" source: git version: "0.0.1" @@ -842,8 +842,8 @@ packages: dependency: "direct overridden" description: path: "." - ref: "8e133d6a60acc5f15ce88fb2d7c9acd603896c55" - resolved-ref: "8e133d6a60acc5f15ce88fb2d7c9acd603896c55" + ref: ea1f7c02e55915f7f96c95d8a476fcb3ca78e694 + resolved-ref: ea1f7c02e55915f7f96c95d8a476fcb3ca78e694 url: "https://github.com/cypherstack/dnssec_resolver.git" source: git version: "0.0.1" @@ -851,8 +851,8 @@ packages: dependency: "direct overridden" description: path: "." - ref: "3dbac4862762f62cb15a01a064b6861a77bb56ea" - resolved-ref: "3dbac4862762f62cb15a01a064b6861a77bb56ea" + ref: "72115493b5ab338cceaa8cdc1eb264653620c303" + resolved-ref: "72115493b5ab338cceaa8cdc1eb264653620c303" url: "https://github.com/cypherstack/doh_transport.git" source: git version: "0.0.1" @@ -1778,7 +1778,7 @@ packages: source: hosted version: "4.5.0" openalias: - dependency: "direct overridden" + dependency: "direct dev" description: path: "." ref: "46d8402773fe3e85f15e4f0be97b92fddcd0a70e" diff --git a/scripts/app_config/templates/pubspec.template.yaml b/scripts/app_config/templates/pubspec.template.yaml index 22960d1d5..0a8346139 100644 --- a/scripts/app_config/templates/pubspec.template.yaml +++ b/scripts/app_config/templates/pubspec.template.yaml @@ -124,7 +124,7 @@ dependencies: cs_openalias: git: url: https://github.com/cypherstack/cs_openalias.git - ref: 15b980d57c8174a131f795543356d5684d2354bc + ref: 84e3e85ced4b41e502063bcaa3314c1ec60adce3 socks_socket: ^2.0.0 local_auth: ^2.3.0 permission_handler: ^13.0.1 @@ -302,6 +302,10 @@ dev_dependencies: isar_community_generator: 3.3.2 # Later drift_dev releases require analyzer >=13; Isar supports <11. drift_dev: 2.34.0 + openalias: + git: + url: https://github.com/cypherstack/openalias.git + ref: 46d8402773fe3e85f15e4f0be97b92fddcd0a70e flutter_native_splash: image: assets/icon/splash.png @@ -321,11 +325,11 @@ dependency_overrides: dnssec_resolver: git: url: https://github.com/cypherstack/dnssec_resolver.git - ref: 8e133d6a60acc5f15ce88fb2d7c9acd603896c55 + ref: ea1f7c02e55915f7f96c95d8a476fcb3ca78e694 doh_transport: git: url: https://github.com/cypherstack/doh_transport.git - ref: 3dbac4862762f62cb15a01a064b6861a77bb56ea + ref: 72115493b5ab338cceaa8cdc1eb264653620c303 # 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 diff --git a/test/pages/send_view/open_alias_preview_test.dart b/test/pages/send_view/open_alias_preview_test.dart index 59233a56a..f93bb44e8 100644 --- a/test/pages/send_view/open_alias_preview_test.dart +++ b/test/pages/send_view/open_alias_preview_test.dart @@ -64,7 +64,10 @@ class _Monero extends Monero { } class _Wownero extends Wownero { - _Wownero() : super(CryptoCurrencyNetwork.main); + _Wownero([this.application]) : super(CryptoCurrencyNetwork.main); + String? application; + @override + String? get openAliasApplication => application; @override bool validateAddress(String address) => _isValid(address); @override @@ -151,9 +154,10 @@ class _Prefs extends ChangeNotifier implements Prefs { } class _Lookup { - _Lookup(this.alias, this.isValidAddress); + _Lookup(this.alias, this.isValidAddress, this.application); final String alias; final bool Function(String) isValidAddress; + final String application; final result = Completer(); int cancels = 0; } @@ -173,18 +177,24 @@ class _Harness { late final service = OpenAliasService( externalCalls: () => !incognito, useTor: () => false, - lookup: (alias, {required isValidAddress, required 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), - ); - } - }); - }, + lookup: + ( + alias, { + required isValidAddress, + required application, + required proxy, + }) { + final lookup = _Lookup(alias, isValidAddress, application); + lookups.add(lookup); + return CsOpenAliasOperation(lookup.result.future, () { + lookup.cancels++; + if (!lookup.result.isCompleted) { + lookup.result.completeError( + CsOpenAliasCancelledException('cancelled', alias: alias), + ); + } + }); + }, ); Future mount( @@ -361,6 +371,43 @@ void main() { expect(h.enabled(tester), isFalse); }); + testWidgets('$layout a coin with its own tag looks up that tag', ( + tester, + ) async { + final h = _Harness(desktop, coin: _Wownero('wow')); + await h.mount(tester); + await h.enter(tester, alias); + await h.setAmount(tester); + expect(h.enabled(tester), isTrue); + await h.preview(tester); + expect(h.lookups.single.application, 'wow'); + h.lookups.single.result.completeError( + const CsOpenAliasNoRecordException('', alias: 'alice.example.com'), + ); + await h.finish(tester); + expect( + find.text( + 'No Wownero OpenAlias record was found for alice.example.com.', + ), + findsOneWidget, + ); + }); + + testWidgets('$layout Preview checks the tag again before a lookup', ( + tester, + ) async { + final coin = _Wownero('wow'); + final h = _Harness(desktop, coin: coin); + await h.mount(tester); + await h.enter(tester, alias); + await h.setAmount(tester); + expect(h.enabled(tester), isTrue); + coin.application = null; + await h.preview(tester); + await h.finish(tester); + expect(h.lookups, isEmpty); + }); + testWidgets('$layout Preview resolves the alias and confirms the address', ( tester, ) async { @@ -371,6 +418,7 @@ void main() { await h.preview(tester); final lookup = h.lookups.single; expect(lookup.alias, alias); + expect(lookup.application, 'xmr'); expect(lookup.isValidAddress(literal), isTrue); expect(lookup.isValidAddress(alias), isFalse); expect(find.text('Cancel'), findsOneWidget); diff --git a/test/services/openalias/cs_openalias_native_test.dart b/test/services/openalias/cs_openalias_native_test.dart index b3ff0c29c..0b91e9a86 100644 --- a/test/services/openalias/cs_openalias_native_test.dart +++ b/test/services/openalias/cs_openalias_native_test.dart @@ -18,12 +18,18 @@ void main() { 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); + final operation = + OpenAliasService( + externalCalls: () => true, + useTor: () => true, + supportsTor: () => true, + torProxy: () => + (host: InternetAddress.loopbackIPv4, port: proxy.port), + ).lookup( + 'alice.example', + isValidAddress: (_) => true, + application: 'xmr', + ); await connected.future.timeout(const Duration(seconds: 10)); operation.cancel(); await expectLater( diff --git a/test/services/openalias/open_alias_live_test.dart b/test/services/openalias/open_alias_live_test.dart index 14322eb4f..996a4f422 100644 --- a/test/services/openalias/open_alias_live_test.dart +++ b/test/services/openalias/open_alias_live_test.dart @@ -17,7 +17,11 @@ void main() { 'live signed alias uses native DNSSEC and Monero address validation', () async { final result = await service - .lookup('donate.getmonero.org', isValidAddress: validate) + .lookup( + 'donate.getmonero.org', + isValidAddress: validate, + application: 'xmr', + ) .result; expect(result.domain, 'donate.getmonero.org'); expect(validate(result.address), isTrue); @@ -27,10 +31,16 @@ void main() { test('live nonexistent name is reported with the alias', () async { await expectLater( - service.lookup('dan@cypherstack', isValidAddress: validate).result, + service + .lookup( + 'dan@cypherstack', + isValidAddress: validate, + application: 'xmr', + ) + .result, throwsA( isA().having( - openAliasErrorMessage, + (e) => openAliasErrorMessage(e, 'Monero'), 'openAliasErrorMessage', 'No Monero OpenAlias record was found for dan.cypherstack.', ), @@ -41,7 +51,9 @@ void main() { 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, + service + .lookup(domain, isValidAddress: validate, application: 'xmr') + .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 index bc7ac7bc8..ec0a8073d 100644 --- a/test/services/openalias/open_alias_service_test.dart +++ b/test/services/openalias/open_alias_service_test.dart @@ -13,16 +13,19 @@ class _Calls { final aliases = []; final proxies = []; final validators = []; + final applications = []; final operations = >[]; CsOpenAliasOperation lookup( String alias, { required bool Function(String address) isValidAddress, + required String application, required CsOpenAliasProxy? proxy, }) { aliases.add(alias); proxies.add(proxy); validators.add(isValidAddress); + applications.add(application); final operation = CsOpenAliasOperation( Future.error(CsOpenAliasNoRecordException('no record', alias: alias)), () {}, @@ -63,7 +66,7 @@ void main() { const CsOpenAliasNetworkException('', alias: alias), const CsOpenAliasCancelledException('', alias: alias), ]) - openAliasErrorMessage(e), + openAliasErrorMessage(e, 'Monero'), }; expect(messages, hasLength(6)); expect( @@ -81,7 +84,11 @@ void main() { ); expect(service.unavailableReason(), incognito); expect( - () => service.lookup('alice@example.com', isValidAddress: (_) => true), + () => service.lookup( + 'alice@example.com', + isValidAddress: (_) => true, + application: 'xmr', + ), throwsA( isA().having( (e) => e.message, @@ -109,7 +116,11 @@ void main() { ); expect(service.unavailableReason(), torUnavailable); expect( - () => service.lookup('alice@example.com', isValidAddress: (_) => true), + () => service.lookup( + 'alice@example.com', + isValidAddress: (_) => true, + application: 'xmr', + ), throwsA( isA().having( (e) => e.message, @@ -133,7 +144,11 @@ void main() { lookup: calls.lookup, ); expect(service.unavailableReason(), isNull); - service.lookup('alice@example.com', isValidAddress: (_) => true); + service.lookup( + 'alice@example.com', + isValidAddress: (_) => true, + application: 'xmr', + ); expect(calls.proxies.single?.address, tor ? '127.0.0.1' : null); expect(calls.proxies.single?.port, tor ? 9050 : null); } @@ -150,9 +165,11 @@ void main() { final operation = service.lookup( ' Alice@Example.com', isValidAddress: validate, + application: 'btc', ); expect(calls.aliases.single, ' Alice@Example.com'); expect(calls.validators.single, same(validate)); + expect(calls.applications.single, 'btc'); expect(operation, same(calls.operations.single)); }); @@ -168,11 +185,19 @@ void main() { useTor: () => false, lookup: calls.lookup, ); - service.lookup('alice@example.com', isValidAddress: (_) => true); + service.lookup( + 'alice@example.com', + isValidAddress: (_) => true, + application: 'xmr', + ); expect(reads, 1); external = false; expect( - () => service.lookup('alice@example.com', isValidAddress: (_) => true), + () => service.lookup( + 'alice@example.com', + isValidAddress: (_) => true, + application: 'xmr', + ), throwsA(isA()), ); expect(reads, 2); diff --git a/test/wallets/open_alias_application_test.dart b/test/wallets/open_alias_application_test.dart new file mode 100644 index 000000000..36b645e4a --- /dev/null +++ b/test/wallets/open_alias_application_test.dart @@ -0,0 +1,25 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:openalias/openalias.dart' show isOpenAliasApplication; +import 'package:stackwallet/app_config.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; + +void main() { + test('every coin has no OpenAlias application or a valid one', () { + for (final coin in AppConfig.coins) { + final application = coin.openAliasApplication; + if (application != null) { + expect( + isOpenAliasApplication(application), + isTrue, + reason: coin.identifier, + ); + } + } + if (AppConfig.coins.any((e) => e is Monero)) { + expect( + AppConfig.coins.map((e) => e.openAliasApplication), + contains('xmr'), + ); + } + }); +}