From 6ad44e98de86cb4eff16fb84b32ee23470055a6b Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 16 Oct 2026 17:23:55 -0500 Subject: [PATCH 1/5] 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/5] 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/5] 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/5] 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/5] 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); }); }