From 6ad44e98de86cb4eff16fb84b32ee23470055a6b Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 16 Oct 2026 17:23:55 -0500 Subject: [PATCH 01/13] 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 02/13] 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 03/13] 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 04/13] 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 05/13] 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 06/13] 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 07/13] 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 08/13] 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'), + ); + } + }); +} From 8bc7a910ee18c4038c4457f3b8206e05c02e639c Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 26 Oct 2026 15:59:35 -0500 Subject: [PATCH 09/13] chore(deps): pin cs_openalias 6c9376e and dnssec_resolver b33d6a2 cs_openalias 6c9376e re-exports isOpenAliasApplication, so the application test imports the facade and openalias is no longer a dev dependency; the dependency_overrides entry stays until the package is published. dnssec_resolver b33d6a2 reports a missing record only for a secure denial, refuses SHA-1-only delegations and backs off its native polling. openalias 46d8402 and doh_transport 7211549 are unchanged. Resolved with `flutter pub get` against the ~/.pub-cache git mirrors of github.com/cypherstack/, each fetched from the local checkout (`git -C ~/.pub-cache/git/cache/- fetch +main:refs/local/main`); the URLs resolve once those SHAs are on GitHub. --- pubspec.lock | 10 +++++----- scripts/app_config/templates/pubspec.template.yaml | 8 ++------ test/wallets/open_alias_application_test.dart | 2 +- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index a00a0e479..87fa19d7f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -520,8 +520,8 @@ packages: dependency: "direct main" description: path: "." - ref: "84e3e85ced4b41e502063bcaa3314c1ec60adce3" - resolved-ref: "84e3e85ced4b41e502063bcaa3314c1ec60adce3" + ref: "6c9376e1a196eec811a04c289e0008d928591427" + resolved-ref: "6c9376e1a196eec811a04c289e0008d928591427" 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: ea1f7c02e55915f7f96c95d8a476fcb3ca78e694 - resolved-ref: ea1f7c02e55915f7f96c95d8a476fcb3ca78e694 + ref: b33d6a2ca9e03884dc0d397e3a73fbb6e482e68d + resolved-ref: b33d6a2ca9e03884dc0d397e3a73fbb6e482e68d url: "https://github.com/cypherstack/dnssec_resolver.git" source: git version: "0.0.1" @@ -1778,7 +1778,7 @@ packages: source: hosted version: "4.5.0" openalias: - dependency: "direct dev" + dependency: "direct overridden" description: path: "." ref: "46d8402773fe3e85f15e4f0be97b92fddcd0a70e" diff --git a/scripts/app_config/templates/pubspec.template.yaml b/scripts/app_config/templates/pubspec.template.yaml index 0a8346139..197718b50 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: 84e3e85ced4b41e502063bcaa3314c1ec60adce3 + ref: 6c9376e1a196eec811a04c289e0008d928591427 socks_socket: ^2.0.0 local_auth: ^2.3.0 permission_handler: ^13.0.1 @@ -302,10 +302,6 @@ 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 @@ -325,7 +321,7 @@ dependency_overrides: dnssec_resolver: git: url: https://github.com/cypherstack/dnssec_resolver.git - ref: ea1f7c02e55915f7f96c95d8a476fcb3ca78e694 + ref: b33d6a2ca9e03884dc0d397e3a73fbb6e482e68d doh_transport: git: url: https://github.com/cypherstack/doh_transport.git diff --git a/test/wallets/open_alias_application_test.dart b/test/wallets/open_alias_application_test.dart index 36b645e4a..40180fd37 100644 --- a/test/wallets/open_alias_application_test.dart +++ b/test/wallets/open_alias_application_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:openalias/openalias.dart' show isOpenAliasApplication; +import 'package:cs_openalias/cs_openalias.dart' show isOpenAliasApplication; import 'package:stackwallet/app_config.dart'; import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; From 2ce39bf4068e645506dc69069124561bbae96b56 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 26 Oct 2026 15:59:35 -0500 Subject: [PATCH 10/13] refactor(openalias): share the lookup between the send views --- lib/pages/send_view/send_view.dart | 63 ++++----- .../wallet_view/sub_widgets/desktop_send.dart | 64 ++++----- .../openalias/open_alias_service.dart | 79 +++++++++++ .../openalias/open_alias_service_test.dart | 127 ++++++++++++++++++ 4 files changed, 263 insertions(+), 70 deletions(-) diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 4bfc02e29..560d069b8 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -1025,9 +1025,14 @@ class _SendViewState extends ConsumerState { /// to. Returns the alias, trimmed and lowercased, or null if the lookup /// failed or was cancelled. Future _resolveOpenAlias(String application) async { - final alias = _address!.trim().toLowerCase(); - CsOpenAliasOperation? operation; - bool cancelled = false; + final resolution = ref + .read(pOpenAliasService) + .resolve( + _address!, + isValidAddress: coin.validateAddress, + application: application, + coinName: coin.prettyName, + ); unawaited( showDialog( context: context, @@ -1037,44 +1042,32 @@ class _SendViewState extends ConsumerState { coin: coin, isSpark: false, onCancel: () { - cancelled = true; - operation?.cancel(); + resolution.cancel(); Navigator.of(context).pop(); }, ), ), ); - try { - operation = ref - .read(pOpenAliasService) - .lookup( - _address!, - isValidAddress: coin.validateAddress, - application: application, - ); - final address = (await operation.result).address; - if (cancelled || !mounted) return null; - Navigator.of(context, rootNavigator: true).pop(); - sendToController.text = address; - _address = address; - _setValidAddressProviders(_address); - return alias; - } catch (e, s) { - if (cancelled || !mounted) return null; - Logging.instance.w("OpenAlias lookup failed", error: e, stackTrace: s); - Navigator.of(context, rootNavigator: true).pop(); - unawaited( - showDialog( - context: context, - builder: (context) => StackOkDialog( - title: openAliasLookupFailedTitle, - message: e is CsOpenAliasException - ? openAliasErrorMessage(e, coin.prettyName) - : e.toString(), + final outcome = await resolution.outcome; + if (!mounted) return null; + switch (outcome) { + case OpenAliasCancelled(): + return null; + case OpenAliasResolved(:final alias, :final address): + Navigator.of(context, rootNavigator: true).pop(); + sendToController.text = address; + _address = address; + _setValidAddressProviders(_address); + return alias; + case OpenAliasFailed(:final title, :final message): + Navigator.of(context, rootNavigator: true).pop(); + unawaited( + showDialog( + context: context, + builder: (context) => StackOkDialog(title: title, message: message), ), - ), - ); - return null; + ); + return null; } } 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 33206f8a1..995627a24 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 @@ -444,9 +444,14 @@ class _DesktopSendState extends ConsumerState { /// to. Returns the alias, trimmed and lowercased, or null if the lookup /// failed or was cancelled. Future _resolveOpenAlias(String application) async { - final alias = _address!.trim().toLowerCase(); - CsOpenAliasOperation? operation; - bool cancelled = false; + final resolution = ref + .read(pOpenAliasService) + .resolve( + _address!, + isValidAddress: coin.validateAddress, + application: application, + coinName: coin.prettyName, + ); unawaited( showDialog( context: context, @@ -461,8 +466,7 @@ class _DesktopSendState extends ConsumerState { coin: coin, isSpark: false, onCancel: () { - cancelled = true; - operation?.cancel(); + resolution.cancel(); Navigator.of(context).pop(); }, ), @@ -470,37 +474,27 @@ class _DesktopSendState extends ConsumerState { ), ), ); - try { - operation = ref - .read(pOpenAliasService) - .lookup( - _address!, - isValidAddress: coin.validateAddress, - application: application, - ); - final address = (await operation.result).address; - if (cancelled || !mounted) return null; - Navigator.of(context, rootNavigator: true).pop(); - sendToController.text = address; - _address = address; - _setValidAddressProviders(_address); - return alias; - } catch (e, s) { - if (cancelled || !mounted) return null; - Logging.instance.w("OpenAlias lookup failed", error: e, stackTrace: s); - Navigator.of(context, rootNavigator: true).pop(); - unawaited( - showDialog( - context: context, - builder: (context) => SimpleDesktopDialog( - title: openAliasLookupFailedTitle, - message: e is CsOpenAliasException - ? openAliasErrorMessage(e, coin.prettyName) - : e.toString(), + final outcome = await resolution.outcome; + if (!mounted) return null; + switch (outcome) { + case OpenAliasCancelled(): + return null; + case OpenAliasResolved(:final alias, :final address): + Navigator.of(context, rootNavigator: true).pop(); + sendToController.text = address; + _address = address; + _setValidAddressProviders(_address); + return alias; + case OpenAliasFailed(:final title, :final message): + Navigator.of(context, rootNavigator: true).pop(); + unawaited( + showDialog( + context: context, + builder: (context) => + SimpleDesktopDialog(title: title, message: message), ), - ), - ); - return null; + ); + return null; } } diff --git a/lib/services/openalias/open_alias_service.dart b/lib/services/openalias/open_alias_service.dart index e2fb63e9c..077fc7f15 100644 --- a/lib/services/openalias/open_alias_service.dart +++ b/lib/services/openalias/open_alias_service.dart @@ -4,6 +4,7 @@ import 'package:cs_openalias/cs_openalias.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../app_config.dart'; +import '../../utilities/logger.dart'; import '../../utilities/prefs.dart'; import '../tor_service.dart'; @@ -28,6 +29,8 @@ String openAliasErrorMessage(CsOpenAliasException e, String coinName) => CsOpenAliasCancelledException() => 'OpenAlias lookup cancelled.', }; +String openAliasKey(String input) => input.trim().toLowerCase(); + typedef OpenAliasLookup = CsOpenAliasOperation Function( String alias, { required bool Function(String address) isValidAddress, @@ -90,6 +93,19 @@ class OpenAliasService { ); } + /// Starts [lookup] for the alias typed into a send field. + OpenAliasResolution resolve( + String input, { + required bool Function(String address) isValidAddress, + required String application, + required String coinName, + }) => OpenAliasResolution._( + () => + lookup(input, isValidAddress: isValidAddress, application: application), + alias: openAliasKey(input), + coinName: coinName, + ); + ({String? unavailable, CsOpenAliasProxy? proxy}) _route() { if (!_externalCalls()) { return ( @@ -120,3 +136,66 @@ class OpenAliasService { ); } } + +sealed class OpenAliasOutcome { + const OpenAliasOutcome(); +} + +final class OpenAliasResolved extends OpenAliasOutcome { + const OpenAliasResolved({required this.alias, required this.address}); + + final String alias; + final String address; +} + +final class OpenAliasFailed extends OpenAliasOutcome { + const OpenAliasFailed(this.message); + + String get title => openAliasLookupFailedTitle; + final String message; +} + +final class OpenAliasCancelled extends OpenAliasOutcome { + const OpenAliasCancelled(); +} + +/// A lookup started by [OpenAliasService.resolve]. +final class OpenAliasResolution { + OpenAliasResolution._( + CsOpenAliasOperation Function() start, { + required String alias, + required String coinName, + }) { + outcome = _run(start, alias, coinName); + } + + late final Future outcome; + CsOpenAliasOperation? _operation; + bool _cancelled = false; + + void cancel() { + _cancelled = true; + _operation?.cancel(); + } + + Future _run( + CsOpenAliasOperation Function() start, + String alias, + String coinName, + ) async { + try { + _operation = start(); + final address = (await _operation!.result).address; + if (_cancelled) return const OpenAliasCancelled(); + return OpenAliasResolved(alias: alias, address: address); + } catch (e, s) { + if (_cancelled) return const OpenAliasCancelled(); + Logging.instance.w("OpenAlias lookup failed", error: e, stackTrace: s); + return OpenAliasFailed( + e is CsOpenAliasException + ? openAliasErrorMessage(e, coinName) + : e.toString(), + ); + } + } +} diff --git a/test/services/openalias/open_alias_service_test.dart b/test/services/openalias/open_alias_service_test.dart index ec0a8073d..363f01aea 100644 --- a/test/services/openalias/open_alias_service_test.dart +++ b/test/services/openalias/open_alias_service_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:cs_openalias/cs_openalias.dart'; @@ -35,6 +36,20 @@ class _Calls { } } +OpenAliasService _service(OpenAliasLookup lookup, {bool external = true}) => + OpenAliasService( + externalCalls: () => external, + useTor: () => false, + lookup: lookup, + ); + +OpenAliasResolution _resolve(OpenAliasService service) => service.resolve( + ' Alice@Example.com', + isValidAddress: (_) => true, + application: 'xmr', + coinName: 'Monero', +); + void main() { test('isOpenAlias accepts names and rejects addresses', () { for (final input in ['Alice@Example.com', 'donate.getmonero.org.']) { @@ -203,4 +218,116 @@ void main() { expect(reads, 2); expect(calls.aliases, hasLength(1)); }); + + group('resolve', () { + test('returns the normalized alias and the address', () async { + final aliases = []; + final service = _service(( + alias, { + required isValidAddress, + required application, + required proxy, + }) { + aliases.add(alias); + return CsOpenAliasOperation( + Future.value( + CsOpenAliasRecipient( + domain: 'alice.example.com', + address: 'resolved', + fields: const {'recipient_address': 'resolved'}, + ), + ), + () {}, + ); + }); + final outcome = await _resolve(service).outcome; + expect(aliases.single, ' Alice@Example.com'); + expect( + outcome, + isA() + .having((e) => e.alias, 'alias', 'alice@example.com') + .having((e) => e.address, 'address', 'resolved'), + ); + }); + + test('a lookup failure becomes its user message', () async { + final service = _service(( + alias, { + required isValidAddress, + required application, + required proxy, + }) { + return CsOpenAliasOperation( + Future.error( + const CsOpenAliasNoRecordException('', alias: 'alice.example.com'), + ), + () {}, + ); + }); + final outcome = await _resolve(service).outcome; + expect( + outcome, + isA() + .having((e) => e.title, 'title', openAliasLookupFailedTitle) + .having( + (e) => e.message, + 'message', + 'No Monero OpenAlias record was found for alice.example.com.', + ), + ); + }); + + test('an unavailable lookup shows the reason', () async { + final calls = _Calls(); + final outcome = await _resolve(_service(calls.lookup, external: false)) + .outcome; + expect( + outcome, + isA().having((e) => e.message, 'message', incognito), + ); + expect(calls.aliases, isEmpty); + }); + + test('any other error shows its text', () async { + final service = _service(( + alias, { + required isValidAddress, + required application, + required proxy, + }) { + throw ArgumentError('bad application'); + }); + final outcome = await _resolve(service).outcome; + expect( + outcome, + isA().having( + (e) => e.message, + 'message', + 'Invalid argument(s): bad application', + ), + ); + }); + + test('cancel cancels the lookup', () async { + final result = Completer(); + var cancels = 0; + final service = _service(( + alias, { + required isValidAddress, + required application, + required proxy, + }) { + return CsOpenAliasOperation(result.future, () { + cancels++; + result.completeError( + CsOpenAliasCancelledException('cancelled', alias: alias), + ); + }); + }); + final resolution = _resolve(service); + resolution.cancel(); + expect(cancels, 1); + expect(await resolution.outcome, isA()); + }); + }); } From 9a5c8f07675bebe5ea80bb90eb9e466b3d8b2b5d Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 26 Oct 2026 16:01:25 -0500 Subject: [PATCH 11/13] fix(openalias): keep the alias when a preview is repeated --- lib/pages/send_view/send_view.dart | 55 +++++++---- .../wallet_view/sub_widgets/desktop_send.dart | 55 +++++++---- .../send_view/open_alias_preview_test.dart | 96 ++++++++++++++++++- 3 files changed, 162 insertions(+), 44 deletions(-) diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 560d069b8..e9daaa390 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -152,6 +152,7 @@ class _SendViewState extends ConsumerState { Amount? _cachedAmountToSend; String? _address; + OpenAliasResolved? _openAlias; /// The [pRecipient] index of each recipient's form while sending to more /// than one recipient, otherwise null. @@ -239,8 +240,13 @@ class _SendViewState extends ConsumerState { } void _addRecipientTapped() { + final resolved = _openAlias; + final address = + resolved != null && resolved.alias == openAliasKey(_address ?? "") + ? resolved.address + : _address ?? ""; _setMultiRecipients([ - (address: _address ?? "", amount: ref.read(pSendAmount), label: null), + (address: address, amount: ref.read(pSendAmount), label: null), (address: "", amount: null, label: null), ]); } @@ -1021,10 +1027,9 @@ 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 + /// Looks up the OpenAlias in the address field. Returns null if the lookup /// failed or was cancelled. - Future _resolveOpenAlias(String application) async { + Future _resolveOpenAlias(String application) async { final resolution = ref .read(pOpenAliasService) .resolve( @@ -1053,12 +1058,9 @@ class _SendViewState extends ConsumerState { switch (outcome) { case OpenAliasCancelled(): return null; - case OpenAliasResolved(:final alias, :final address): + case OpenAliasResolved(): Navigator.of(context, rootNavigator: true).pop(); - sendToController.text = address; - _address = address; - _setValidAddressProviders(_address); - return alias; + return outcome; case OpenAliasFailed(:final title, :final message): Navigator.of(context, rootNavigator: true).pop(); unawaited( @@ -1077,13 +1079,21 @@ class _SendViewState extends ConsumerState { await Future.delayed(const Duration(milliseconds: 100)); final wallet = ref.read(pWallets).getWallet(walletId); String? openAlias; + String? resolvedAddress; final application = coin.openAliasApplication; if (application != null && !coin.validateAddress(_address ?? "") && isOpenAlias(_address ?? "")) { - openAlias = await _resolveOpenAlias(application); - if (openAlias == null || !mounted) return; + var resolved = _openAlias; + if (resolved?.alias != openAliasKey(_address!)) { + resolved = _openAlias = await _resolveOpenAlias(application); + } + if (resolved == null || !mounted) return; + openAlias = resolved.alias; + resolvedAddress = resolved.address; + _setValidAddressProviders(resolvedAddress); } + late final recipientAddress = resolvedAddress ?? _address!; final Amount amount = ref.read(pSendAmount)!; final Amount availableBalance; @@ -1227,7 +1237,7 @@ class _SendViewState extends ConsumerState { txData: TxData( sparkRecipients: [ ( - address: _address!, + address: recipientAddress, amount: amount, memo: memoController.text, isChange: false, @@ -1246,11 +1256,11 @@ class _SendViewState extends ConsumerState { txData: TxData( recipients: [ TxRecipient( - address: _address!, + address: recipientAddress, amount: amount, isChange: false, addressType: wallet.cryptoCurrency.getAddressType( - _address!, + recipientAddress, )!, ), ], @@ -1273,18 +1283,18 @@ class _SendViewState extends ConsumerState { ? null : [ TxRecipient( - address: _address!, + address: recipientAddress, amount: amount, isChange: false, addressType: wallet.cryptoCurrency.getAddressType( - _address!, + recipientAddress, )!, ), ], sparkRecipients: ref.read(pValidSparkSendToAddress) ? [ ( - address: _address!, + address: recipientAddress, amount: amount, memo: memoController.text, isChange: false, @@ -1303,10 +1313,12 @@ class _SendViewState extends ConsumerState { txData: TxData( recipients: [ TxRecipient( - address: _address!, + address: recipientAddress, amount: amount, isChange: false, - addressType: wallet.cryptoCurrency.getAddressType(_address!)!, + addressType: wallet.cryptoCurrency.getAddressType( + recipientAddress, + )!, ), ], openAlias: openAlias, @@ -1331,11 +1343,11 @@ class _SendViewState extends ConsumerState { _multiRecipients?.toTxRecipients(wallet.cryptoCurrency) ?? [ TxRecipient( - address: _address!, + address: recipientAddress, amount: amount, isChange: false, addressType: wallet.cryptoCurrency.getAddressType( - _address!, + recipientAddress, )!, ), ], @@ -1445,6 +1457,7 @@ class _SendViewState extends ConsumerState { if (!mounted) { return; } + _openAlias = null; sendToController.text = ""; cryptoAmountController.text = ""; baseAmountController.text = ""; 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 995627a24..6e12a30bd 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 @@ -139,6 +139,7 @@ class _DesktopSendState extends ConsumerState { Amount? _cachedAmountToSend; String? _address; + OpenAliasResolved? _openAlias; /// The [pRecipient] index of each recipient's form while sending to more /// than one recipient, otherwise null. @@ -440,10 +441,9 @@ 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 + /// Looks up the OpenAlias in the address field. Returns null if the lookup /// failed or was cancelled. - Future _resolveOpenAlias(String application) async { + Future _resolveOpenAlias(String application) async { final resolution = ref .read(pOpenAliasService) .resolve( @@ -479,12 +479,9 @@ class _DesktopSendState extends ConsumerState { switch (outcome) { case OpenAliasCancelled(): return null; - case OpenAliasResolved(:final alias, :final address): + case OpenAliasResolved(): Navigator.of(context, rootNavigator: true).pop(); - sendToController.text = address; - _address = address; - _setValidAddressProviders(_address); - return alias; + return outcome; case OpenAliasFailed(:final title, :final message): Navigator.of(context, rootNavigator: true).pop(); unawaited( @@ -505,13 +502,21 @@ class _DesktopSendState extends ConsumerState { final wallet = ref.read(pWallets).getWallet(walletId); String? openAlias; + String? resolvedAddress; final application = coin.openAliasApplication; if (application != null && !coin.validateAddress(_address ?? "") && isOpenAlias(_address ?? "")) { - openAlias = await _resolveOpenAlias(application); - if (openAlias == null || !mounted) return; + var resolved = _openAlias; + if (resolved?.alias != openAliasKey(_address!)) { + resolved = _openAlias = await _resolveOpenAlias(application); + } + if (resolved == null || !mounted) return; + openAlias = resolved.alias; + resolvedAddress = resolved.address; + _setValidAddressProviders(resolvedAddress); } + late final recipientAddress = resolvedAddress ?? _address!; // Handle MWC slatepack transactions directly. if (isMimblewimblecoin && ref.read(pIsSlatepack(widget.walletId))) { @@ -701,7 +706,7 @@ class _DesktopSendState extends ConsumerState { txData: TxData( sparkRecipients: [ ( - address: _address!, + address: recipientAddress, amount: amount, memo: memoController.text, isChange: false, @@ -722,11 +727,11 @@ class _DesktopSendState extends ConsumerState { txData: TxData( recipients: [ TxRecipient( - address: _address!, + address: recipientAddress, amount: amount, isChange: false, addressType: wallet.cryptoCurrency.getAddressType( - _address!, + recipientAddress, )!, ), ], @@ -751,18 +756,18 @@ class _DesktopSendState extends ConsumerState { ? null : [ TxRecipient( - address: _address!, + address: recipientAddress, amount: amount, isChange: false, addressType: wallet.cryptoCurrency.getAddressType( - _address!, + recipientAddress, )!, ), ], sparkRecipients: ref.read(pValidSparkSendToAddress) ? [ ( - address: _address!, + address: recipientAddress, amount: amount, memo: memoController.text, isChange: false, @@ -781,10 +786,12 @@ class _DesktopSendState extends ConsumerState { txData: TxData( recipients: [ TxRecipient( - address: _address!, + address: recipientAddress, amount: amount, isChange: false, - addressType: wallet.cryptoCurrency.getAddressType(_address!)!, + addressType: wallet.cryptoCurrency.getAddressType( + recipientAddress, + )!, ), ], openAlias: openAlias, @@ -808,11 +815,11 @@ class _DesktopSendState extends ConsumerState { _multiRecipients?.toTxRecipients(wallet.cryptoCurrency) ?? [ TxRecipient( - address: _address!, + address: recipientAddress, amount: amount, isChange: false, addressType: wallet.cryptoCurrency.getAddressType( - _address!, + recipientAddress, )!, ), ], @@ -949,6 +956,7 @@ class _DesktopSendState extends ConsumerState { if (!mounted) { return; } + _openAlias = null; sendToController.text = ""; cryptoAmountController.text = ""; baseAmountController.text = ""; @@ -1170,8 +1178,13 @@ class _DesktopSendState extends ConsumerState { } void _addRecipientTapped() { + final resolved = _openAlias; + final address = + resolved != null && resolved.alias == openAliasKey(_address ?? "") + ? resolved.address + : _address ?? ""; _setMultiRecipients([ - (address: _address ?? "", amount: ref.read(pSendAmount), label: null), + (address: address, amount: ref.read(pSendAmount), label: null), (address: "", amount: null, label: null), ]); } diff --git a/test/pages/send_view/open_alias_preview_test.dart b/test/pages/send_view/open_alias_preview_test.dart index f93bb44e8..b64bcbc16 100644 --- a/test/pages/send_view/open_alias_preview_test.dart +++ b/test/pages/send_view/open_alias_preview_test.dart @@ -433,7 +433,70 @@ void main() { expect(find.text('OpenAlias'), findsOneWidget); expect(find.text('alice@example.com'), findsOneWidget); expect(find.text(literal), findsWidgets); - expect(h.text(tester), literal); + expect(h.text(tester), alias); + }); + + testWidgets('$layout a repeated Preview reuses the resolved alias', ( + tester, + ) async { + final h = _Harness(desktop); + await h.mount(tester); + await h.enter(tester, alias); + await h.setAmount(tester); + await h.preview(tester); + h.succeed(); + await h.finish(tester); + expect(find.byType(ConfirmTransactionView), findsOneWidget); + + h.navigator.currentState!.pop(); + await tester.pumpAndSettle(); + expect(find.byType(ConfirmTransactionView), findsNothing); + expect(h.text(tester), alias); + expect(h.enabled(tester), isTrue); + + await h.preview(tester); + await h.finish(tester); + expect(h.lookups, hasLength(1)); + expect(h.wallet.prepared, hasLength(2)); + expect(h.wallet.prepared.last.recipients!.single.address, literal); + expect(h.wallet.prepared.last.openAlias, 'alice@example.com'); + 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); + }); + + testWidgets('$layout an edited alias is looked up again', (tester) async { + final h = _Harness(desktop); + await h.mount(tester); + await h.enter(tester, alias); + await h.setAmount(tester); + await h.preview(tester); + h.succeed(); + await h.finish(tester); + h.navigator.currentState!.pop(); + await tester.pumpAndSettle(); + + await h.enter(tester, 'bob@example.com'); + await h.preview(tester); + expect(h.lookups, hasLength(2)); + expect(h.lookups.last.alias, 'bob@example.com'); + h.lookups.last.result.complete( + CsOpenAliasRecipient( + domain: 'bob.example.com', + address: 'second-recipient', + fields: const {'recipient_address': 'second-recipient'}, + ), + ); + await h.finish(tester); + expect( + h.confirmed(tester).recipients!.single.address, + 'second-recipient', + ); + expect(h.confirmed(tester).openAlias, 'bob@example.com'); + expect(h.text(tester), 'bob@example.com'); }); testWidgets('$layout the lookup runs before the send-all confirmation', ( @@ -450,7 +513,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Confirm send all'), findsOneWidget); expect(h.wallet.prepared, isEmpty); - expect(h.text(tester), literal); + expect(h.text(tester), alias); }); testWidgets('$layout Cancel during the lookup cancels it', (tester) async { @@ -541,6 +604,35 @@ void main() { }); } + testWidgets('$layout Add recipient after a Preview carries 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); + h.succeed(); + await h.finish(tester); + h.navigator.currentState!.pop(); + await tester.pumpAndSettle(); + + 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)); + expect( + find.descendant(of: forms.first, matching: find.text(literal)), + findsOneWidget, + ); + expect(h.lookups, hasLength(1)); + }); + testWidgets('$layout multiple recipients never start a lookup', ( tester, ) async { From 938961e4df71623fd7960daa9aea07de83b4e5dc Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 26 Oct 2026 16:01:25 -0500 Subject: [PATCH 12/13] fix(openalias): show one generic text for non-facade lookup errors --- lib/services/openalias/open_alias_service.dart | 10 +++++----- .../services/openalias/open_alias_service_test.dart | 13 ++++++++++--- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/lib/services/openalias/open_alias_service.dart b/lib/services/openalias/open_alias_service.dart index 077fc7f15..d3e817500 100644 --- a/lib/services/openalias/open_alias_service.dart +++ b/lib/services/openalias/open_alias_service.dart @@ -191,11 +191,11 @@ final class OpenAliasResolution { } catch (e, s) { if (_cancelled) return const OpenAliasCancelled(); Logging.instance.w("OpenAlias lookup failed", error: e, stackTrace: s); - return OpenAliasFailed( - e is CsOpenAliasException - ? openAliasErrorMessage(e, coinName) - : e.toString(), - ); + return OpenAliasFailed(switch (e) { + CsOpenAliasException() => openAliasErrorMessage(e, coinName), + OpenAliasUnavailableException(:final message) => message, + _ => 'The OpenAlias lookup failed.', + }); } } } diff --git a/test/services/openalias/open_alias_service_test.dart b/test/services/openalias/open_alias_service_test.dart index 363f01aea..f6971a721 100644 --- a/test/services/openalias/open_alias_service_test.dart +++ b/test/services/openalias/open_alias_service_test.dart @@ -288,7 +288,7 @@ void main() { expect(calls.aliases, isEmpty); }); - test('any other error shows its text', () async { + test('any other error is logged, not shown', () async { final service = _service(( alias, { required isValidAddress, @@ -297,15 +297,22 @@ void main() { }) { throw ArgumentError('bad application'); }); - final outcome = await _resolve(service).outcome; + final logged = []; + final outcome = await runZoned( + () => _resolve(service).outcome, + zoneSpecification: ZoneSpecification( + print: (_, _, _, line) => logged.add(line), + ), + ); expect( outcome, isA().having( (e) => e.message, 'message', - 'Invalid argument(s): bad application', + 'The OpenAlias lookup failed.', ), ); + expect(logged.single, contains('bad application')); }); test('cancel cancels the lookup', () async { From 04c365f822d1697b9e245159041332ea0c0c36d1 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 26 Oct 2026 16:01:25 -0500 Subject: [PATCH 13/13] docs(openalias): say why a disconnected Tor refuses the lookup --- lib/services/openalias/open_alias_service.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/services/openalias/open_alias_service.dart b/lib/services/openalias/open_alias_service.dart index d3e817500..2788f90ba 100644 --- a/lib/services/openalias/open_alias_service.dart +++ b/lib/services/openalias/open_alias_service.dart @@ -127,7 +127,9 @@ class OpenAliasService { proxy: CsOpenAliasProxy(address: info.host.address, port: info.port), ); } catch (_) { - // Tor is not connected; never fall back to a direct lookup. + // Tor is not connected. Unlike Electrum, never fall back to clearnet, + // even with the kill switch off: the resolver and the network path + // would see this address asking for the alias domain. } } return (