From 12d7146a6c46b771a571fe7b855cfaff5f809c9c Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 28 Sep 2026 12:42:51 -0500 Subject: [PATCH 01/17] chore(send): remove dead commented-out OpenAlias bottom sheet --- .../sub_widgets/openalias_sheet.dart | 151 ------------------ 1 file changed, 151 deletions(-) delete mode 100644 lib/pages/send_view/sub_widgets/openalias_sheet.dart diff --git a/lib/pages/send_view/sub_widgets/openalias_sheet.dart b/lib/pages/send_view/sub_widgets/openalias_sheet.dart deleted file mode 100644 index d2bf5d302d..0000000000 --- a/lib/pages/send_view/sub_widgets/openalias_sheet.dart +++ /dev/null @@ -1,151 +0,0 @@ -// /* -// * This file is part of Stack Wallet. -// * -// * Copyright (c) 2023 Cypher Stack -// * All Rights Reserved. -// * The code is distributed under GPLv3 license, see LICENSE file for details. -// * Generated by Cypher Stack on 2023-05-26 -// * -// */ -// -// import 'package:basic_utils/basic_utils.dart'; -// import 'package:flutter/material.dart'; -// import 'package:flutter_riverpod/flutter_riverpod.dart'; -// import 'package:stackwallet/themes/stack_colors.dart'; -// import 'package:stackwallet/utilities/constants.dart'; -// import 'package:stackwallet/utilities/text_styles.dart'; -// -// class OpenAliasBottomSheet extends ConsumerStatefulWidget { -// const OpenAliasBottomSheet({ -// Key? key, -// required this.onSelected, -// }) : super(key: key); -// -// final Null Function(String) onSelected; -// -// @override -// ConsumerState createState() => -// _OpenAliasBottomSheetState(); -// } -// -// class _OpenAliasBottomSheetState extends ConsumerState { -// late TextEditingController textEditingController; -// -// @override -// void initState() { -// super.initState(); -// textEditingController = TextEditingController(); -// } -// -// @override -// Widget build(BuildContext context) { -// return SingleChildScrollView( -// child: Padding( -// padding: -// EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), -// child: Container( -// decoration: BoxDecoration( -// color: Theme.of(context).extension()!.popupBG, -// borderRadius: const BorderRadius.vertical( -// top: Radius.circular(20), -// ), -// ), -// child: Padding( -// padding: const EdgeInsets.only( -// top: 20, -// left: 20, -// right: 20, -// bottom: 20, -// ), -// child: Column( -// mainAxisSize: MainAxisSize.min, -// crossAxisAlignment: CrossAxisAlignment.start, -// children: [ -// Center( -// child: Container( -// decoration: BoxDecoration( -// color: Theme.of(context) -// .extension()! -// .textFieldDefaultBG, -// borderRadius: BorderRadius.circular( -// Constants.size.circularBorderRadius, -// ), -// ), -// width: 60, -// height: 4, -// ), -// ), -// const SizedBox( -// height: 36, -// ), -// Text( -// 'OpenAlias Address', -// style: STextStyles.smallMed12(context), -// textAlign: TextAlign.left, -// ), -// const SizedBox( -// height: 8, -// ), -// TextField( -// controller: textEditingController, -// decoration: const InputDecoration( -// border: OutlineInputBorder(), -// ), -// ), -// const SizedBox( -// height: 16, -// ), -// Align( -// alignment: Alignment.bottomCenter, -// child: ConstrainedBox( -// constraints: const BoxConstraints( -// minWidth: 480, -// minHeight: 70, -// ), -// child: TextButton( -// style: Theme.of(context) -// .extension()! -// .getPrimaryEnabledButtonStyle(context), -// onPressed: () async { -// String text = textEditingController.text -// .trim() -// .replaceAll("@", "."); -// List? result = -// await DnsUtils.lookupRecord(text, RRecordType.TXT); -// String address = ""; -// if (result != null && result.isNotEmpty) { -// for (RRecord record in result) { -// if (record.data.startsWith("oa1:xmr")) { -// List datas = record.data.split(" "); -// for (String data in datas) { -// if (data.startsWith("recipient_address=")) { -// address = data -// .substring("recipient_address=".length) -// .replaceAll(";", ""); -// break; -// } -// } -// break; -// } -// } -// } -// widget.onSelected(address); -// if (mounted) { -// Navigator.of(context).pop(); -// } -// }, -// child: Text( -// "Enter", -// style: STextStyles.button(context), -// ), -// ), -// ), -// ) -// ], -// ), -// ), -// ), -// ), -// ); -// } -// } From a9554425c2e0179ec564219d3ad10bd2edd19f71 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 28 Sep 2026 12:54:38 -0500 Subject: [PATCH 02/17] feat(openalias): implement OpenAlias lookup Parse oa1:xmr records, rejecting separate payment IDs and aliases with more than one distinct address. Records are resolved with Google DNS-over-HTTPS and only accepted when authenticated (AD set, CD and TC clear, exact question match). When Tor is enabled the lookup is tunnelled through its SOCKS port to the numeric resolver address, with no clearnet fallback. --- .../openalias/dns_proxy_connection.dart | 70 +++++++ lib/services/openalias/doh_open_alias.dart | 178 ++++++++++++++++++ lib/services/openalias/open_alias.dart | 139 ++++++++++++++ .../openalias/open_alias_service.dart | 67 +++++++ 4 files changed, 454 insertions(+) create mode 100644 lib/services/openalias/dns_proxy_connection.dart create mode 100644 lib/services/openalias/doh_open_alias.dart create mode 100644 lib/services/openalias/open_alias.dart create mode 100644 lib/services/openalias/open_alias_service.dart diff --git a/lib/services/openalias/dns_proxy_connection.dart b/lib/services/openalias/dns_proxy_connection.dart new file mode 100644 index 0000000000..0cb57b204c --- /dev/null +++ b/lib/services/openalias/dns_proxy_connection.dart @@ -0,0 +1,70 @@ +import 'dart:io'; + +import 'package:async/async.dart'; + +// socks5_proxy resolves hostnames locally before CONNECT, so tunnel to the +// numeric endpoint instead. +class DnsProxyConnection { + Socket? _transport; + bool _cancelled = false; + late final Future socket; + + DnsProxyConnection(InternetAddress proxyHost, int proxyPort) { + socket = _connect(proxyHost, proxyPort); + } + + Future _connect(InternetAddress host, int port) async { + try { + final raw = await Socket.connect( + host, + port, + timeout: const Duration(seconds: 5), + ); + _transport = raw; + if (_cancelled) throw const SocketException('DNS connection cancelled'); + // SecureSocket.secure takes over this reader's subscription. + final reader = ChunkedStreamReader(raw); + Future> read(int count) async { + final bytes = await reader.readBytes(count); + if (bytes.length != count) { + throw const SocketException('Incomplete SOCKS response'); + } + return bytes; + } + + raw.add([5, 1, 0]); + final greeting = await read(2); + if (greeting[0] != 5 || greeting[1] != 0) { + throw const SocketException('SOCKS authentication failed'); + } + raw.add([5, 1, 0, 1, 8, 8, 8, 8, 1, 187]); // CONNECT 8.8.8.8:443. + final reply = await read(4); + if (reply[0] != 5 || reply[1] != 0 || reply[2] != 0) { + throw const SocketException('SOCKS connection failed'); + } + switch (reply[3]) { + case 1: + await read(4); + case 4: + await read(16); + case 3: + await read((await read(1)).single); + default: + throw const SocketException('Invalid SOCKS address type'); + } + await read(2); + final secured = await SecureSocket.secure(raw, host: 'dns.google'); + _transport = secured; + if (_cancelled) throw const SocketException('DNS connection cancelled'); + return secured; + } catch (_) { + cancel(); + rethrow; + } + } + + void cancel() { + _cancelled = true; + _transport?.destroy(); + } +} diff --git a/lib/services/openalias/doh_open_alias.dart b/lib/services/openalias/doh_open_alias.dart new file mode 100644 index 0000000000..900371075a --- /dev/null +++ b/lib/services/openalias/doh_open_alias.dart @@ -0,0 +1,178 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'dns_proxy_connection.dart'; +import 'open_alias.dart'; + +class DohOpenAlias { + Future> lookup( + String domain, { + required ({InternetAddress host, int port})? proxyInfo, + }) async { + final client = HttpClient()..connectionTimeout = const Duration(seconds: 5); + final tunnels = []; + try { + if (proxyInfo != null) { + client.connectionFactory = (uri, _, _) async { + final task = DnsProxyConnection(proxyInfo.host, proxyInfo.port); + tunnels.add(task); + return ConnectionTask.fromSocket(task.socket, task.cancel); + }; + } + return await _request( + client, + domain, + ).timeout(const Duration(seconds: 12)); + } on OpenAliasException { + rethrow; + } catch (_) { + throw const OpenAliasException( + 'OpenAlias lookup failed. Check your connection and try again.', + ); + } finally { + client.close(force: true); + for (final task in tunnels) { + task.cancel(); + } + } + } + + Future> _request(HttpClient client, String domain) async { + final uri = Uri.https('dns.google', '/resolve', { + 'name': '$domain.', + 'type': 'TXT', + 'do': 'true', + 'cd': 'false', + 'edns_client_subnet': '0.0.0.0/0', + }); + final request = await client.getUrl(uri); + request.followRedirects = false; + request.headers.set('Accept', 'application/dns-json'); + final response = await request.close(); + if (response.statusCode != 200) { + throw const OpenAliasException( + 'The DNS resolver is unavailable. Try again later.', + ); + } + final bytes = []; + await for (final chunk in response) { + if (bytes.length + chunk.length > 65536) { + throw const OpenAliasException('The DNS response is too large.'); + } + bytes.addAll(chunk); + } + return decodeAuthenticatedDns(jsonDecode(utf8.decode(bytes)), domain); + } +} + +String _dnsName(Object? value) { + if (value is! String || value.contains('@')) { + throw const OpenAliasException('Invalid DNS response.'); + } + return normalizeOpenAlias(value); +} + +List decodeAuthenticatedDns(Object? body, String domain) { + if (body is! Map) { + throw const OpenAliasException('Invalid DNS response.'); + } + if (body['Status'] != 0) { + throw const OpenAliasException( + 'DNS lookup failed or no record exists. No address was accepted.', + ); + } + if (body['AD'] != true || body['CD'] != false || body['TC'] != false) { + throw const OpenAliasException( + 'DNSSEC verification failed or is unavailable. ' + 'Ask the recipient for a verified alias or address.', + ); + } + final questions = body['Question']; + if (questions is! List || + questions.length != 1 || + questions.single is! Map || + questions.single['type'] != 16 || + _dnsName(questions.single['name']) != domain) { + throw const OpenAliasException( + 'The DNS response does not match this alias.', + ); + } + final answers = body['Answer']; + if (answers == null) return []; + if (answers is! List || + answers.length > 64 || + answers.any((a) => a is! Map)) { + throw const OpenAliasException('Invalid DNS response.'); + } + final visited = {}; + var owner = domain; + for (var i = 0; i < 8; i++) { + if (!visited.add(owner)) { + throw const OpenAliasException('The alias has a DNS loop.'); + } + final atOwner = answers.where((a) => _dnsName(a['name']) == owner).toList(); + final cnames = atOwner.where((a) => a['type'] == 5).toList(); + final txt = atOwner.where((a) => a['type'] == 16).toList(); + if (cnames.isEmpty) { + return txt.map((a) { + if (a['data'] is! String || a['TTL'] is! int || (a['TTL'] as int) < 0) { + throw const OpenAliasException('Invalid TXT record.'); + } + return decodeDnsTxt(a['data'] as String); + }).toList(); + } + if (cnames.length != 1 || txt.isNotEmpty) { + throw const OpenAliasException('The DNS alias is ambiguous.'); + } + owner = _dnsName(cnames.single['data']); + } + throw const OpenAliasException('The DNS alias chain is too long.'); +} + +String decodeDnsTxt(String value) { + if (value.length > 8192) { + throw const OpenAliasException('The TXT record is too large.'); + } + if (!value.startsWith('"')) return value; + final out = StringBuffer(); + var i = 0; + while (i < value.length) { + while (i < value.length && value[i] == ' ') { + i++; + } + if (i == value.length) break; + if (value[i++] != '"') { + throw const OpenAliasException('Malformed DNS TXT encoding.'); + } + var closed = false; + while (i < value.length) { + final char = value[i++]; + if (char == '"') { + closed = true; + break; + } + if (char != '\\') { + out.write(char); + continue; + } + if (i >= value.length) { + throw const OpenAliasException('Malformed DNS TXT escape.'); + } + if (RegExp(r'[0-9]').hasMatch(value[i])) { + if (i + 3 > value.length) { + throw const OpenAliasException('Malformed DNS TXT escape.'); + } + final octet = int.tryParse(value.substring(i, i + 3)); + if (octet == null || octet > 255) { + throw const OpenAliasException('Malformed DNS TXT escape.'); + } + out.writeCharCode(octet); + i += 3; + } else { + out.write(value[i++]); + } + } + if (!closed) throw const OpenAliasException('Malformed DNS TXT encoding.'); + } + return out.toString(); +} diff --git a/lib/services/openalias/open_alias.dart b/lib/services/openalias/open_alias.dart new file mode 100644 index 0000000000..9633923558 --- /dev/null +++ b/lib/services/openalias/open_alias.dart @@ -0,0 +1,139 @@ +class OpenAliasRecipient { + final String domain; + final String address; + + const OpenAliasRecipient({required this.domain, required this.address}); +} + +class OpenAliasException implements Exception { + final String message; + const OpenAliasException(this.message); + @override + String toString() => message; +} + +String normalizeOpenAlias(String input) { + var name = input.trim(); + if (name.codeUnits.any((code) => code > 127)) { + throw const OpenAliasException( + 'Use punycode for international domain names.', + ); + } + name = name.toLowerCase(); + if (name.split('@').length > 2) { + throw const OpenAliasException( + 'Enter a domain or an email-style OpenAlias.', + ); + } + name = name.replaceAll('@', '.'); + if (name.endsWith('.')) name = name.substring(0, name.length - 1); + final label = RegExp(r'^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$'); + if (name.length > 253 || + !name.contains('.') || + !name.split('.').every(label.hasMatch) || + RegExp(r'^[0-9.]+$').hasMatch(name) || + name.endsWith('.onion')) { + throw const OpenAliasException( + 'Enter a valid domain. Use punycode for international names.', + ); + } + return name; +} + +Map? parseOpenAliasRecord(String record) { + if (record.length > 4096) { + throw const OpenAliasException('The OpenAlias record is too large.'); + } + final prefix = RegExp(r'^oa1:xmr(?:\s|$)').firstMatch(record); + if (prefix == null) return null; + final fields = {}; + final body = record.substring(prefix.end); + final parts = []; + var quoted = false; + var escaped = false; + var field = StringBuffer(); + for (final code in body.codeUnits) { + final char = String.fromCharCode(code); + if (code < 32 && char != '\t') { + throw const OpenAliasException('The OpenAlias record is malformed.'); + } + if (escaped) { + field.write(char); + escaped = false; + continue; + } + if (char == '\\') { + escaped = true; + continue; + } + if (char == '"') quoted = !quoted; + if (char == ';' && !quoted) { + parts.add(field.toString()); + field = StringBuffer(); + } else { + field.write(char); + } + } + if (quoted || escaped) { + throw const OpenAliasException('The OpenAlias record is malformed.'); + } + parts.add(field.toString()); + for (final part in parts) { + if (part.trim().isEmpty) continue; + final equals = part.indexOf('='); + if (equals < 1) { + throw const OpenAliasException('The OpenAlias record is malformed.'); + } + final key = part.substring(0, equals).trim(); + var value = part.substring(equals + 1).trim(); + if (!RegExp(r'^[a-z_]+$').hasMatch(key) || fields.containsKey(key)) { + throw const OpenAliasException( + 'The OpenAlias record has duplicate or invalid fields.', + ); + } + if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) { + value = value.substring(1, value.length - 1); + } else if (value.contains('"')) { + throw const OpenAliasException('The OpenAlias record is malformed.'); + } + fields[key] = value; + } + return fields; +} + +OpenAliasRecipient selectOpenAliasRecipient({ + required String domain, + required List records, + required bool Function(String) validateAddress, +}) { + if (records.length > 64) { + throw const OpenAliasException('Too many DNS records.'); + } + final addresses = {}; + for (final record in records) { + final fields = parseOpenAliasRecord(record); + if (fields == null) continue; + if (fields['tx_payment_id']?.isNotEmpty ?? false) { + throw const OpenAliasException( + 'This alias requires a separate payment ID, which is not supported.', + ); + } + final address = fields['recipient_address']; + if (address == null || !validateAddress(address)) { + throw const OpenAliasException( + 'This alias contains an invalid Monero address for this wallet.', + ); + } + addresses.add(address); + } + if (addresses.isEmpty) { + throw const OpenAliasException('No Monero OpenAlias record was found.'); + } + if (addresses.length != 1) { + throw const OpenAliasException( + 'This alias has multiple Monero addresses. ' + 'Ask the recipient for an address.', + ); + } + return OpenAliasRecipient(domain: domain, address: addresses.single); +} diff --git a/lib/services/openalias/open_alias_service.dart b/lib/services/openalias/open_alias_service.dart new file mode 100644 index 0000000000..ac19b2400f --- /dev/null +++ b/lib/services/openalias/open_alias_service.dart @@ -0,0 +1,67 @@ +import '../../app_config.dart'; +import '../../utilities/prefs.dart'; +import '../tor_service.dart'; +import 'doh_open_alias.dart'; +import 'open_alias.dart'; + +class OpenAliasService { + final bool Function() _externalCalls; + final bool Function() _useTor; + final bool Function() _supportsTor; + final Future> Function(String, bool) _lookup; + + OpenAliasService({ + bool Function()? externalCalls, + bool Function()? useTor, + bool Function()? supportsTor, + Future> Function(String, bool)? lookup, + }) : _externalCalls = externalCalls ?? (() => Prefs.instance.externalCalls), + _useTor = useTor ?? (() => Prefs.instance.useTor), + _supportsTor = + supportsTor ?? (() => AppConfig.hasFeature(AppFeature.tor)), + _lookup = lookup ?? _lookupDns; + + static Future> _lookupDns(String domain, bool useTor) => + DohOpenAlias().lookup( + domain, + proxyInfo: useTor ? TorService.sharedInstance.getProxyInfo() : null, + ); + + Future resolve( + String input, { + required bool Function(String) validateAddress, + }) async { + final domain = normalizeOpenAlias(input); + if (!_externalCalls()) { + throw const OpenAliasException( + 'OpenAlias requires external lookups. ' + 'Enable external calls in privacy settings to continue.', + ); + } + final useTor = _useTor(); + if (useTor && !_supportsTor()) { + throw const OpenAliasException('Tor is unavailable. No lookup was sent.'); + } + try { + final records = await _lookup(domain, useTor); + if (_useTor() != useTor || !_externalCalls()) { + throw const OpenAliasException( + 'Privacy settings changed. ' + 'Please look up the alias again.', + ); + } + return selectOpenAliasRecipient( + domain: domain, + records: records, + validateAddress: validateAddress, + ); + } on OpenAliasException { + rethrow; + } catch (_) { + throw const OpenAliasException( + 'OpenAlias lookup is unavailable. ' + 'Check your connection and Tor settings.', + ); + } + } +} From 672c65a4eb9d112aed5920a6bb7c1547d91f455c Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 28 Sep 2026 12:54:38 -0500 Subject: [PATCH 03/17] feat(send): integrate OpenAlias into Monero send Add a Use OpenAlias lookup to the mobile and desktop send screens. The accepted address is carried in TxData and shown on confirmation; editing the destination drops it. --- .../send_view/confirm_transaction_view.dart | 203 ++++++++----- lib/pages/send_view/send_view.dart | 70 ++++- .../wallet_view/sub_widgets/desktop_send.dart | 71 ++++- lib/wallets/models/tx_data.dart | 10 + lib/widgets/open_alias_dialog.dart | 283 ++++++++++++++++++ 5 files changed, 537 insertions(+), 100 deletions(-) create mode 100644 lib/widgets/open_alias_dialog.dart diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index ed4e967792..672bf9c726 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -195,9 +195,8 @@ class _ConfirmTransactionViewState if (context.mounted) { widget.onSuccess.call(); if (widget.onSuccessInsteadOfRouteOnSuccess == null) { - Navigator.of( - context, - ).popUntil(ModalRoute.withName(routeOnSuccessName)); + Navigator.of(context) + .popUntil(ModalRoute.withName(routeOnSuccessName)); } else { widget.onSuccessInsteadOfRouteOnSuccess!.call(); } @@ -274,9 +273,8 @@ class _ConfirmTransactionViewState if (context.mounted) { widget.onSuccess.call(); if (widget.onSuccessInsteadOfRouteOnSuccess == null) { - Navigator.of( - context, - ).popUntil(ModalRoute.withName(routeOnSuccessName)); + Navigator.of(context) + .popUntil(ModalRoute.withName(routeOnSuccessName)); } else { widget.onSuccessInsteadOfRouteOnSuccess!.call(); } @@ -483,9 +481,8 @@ class _ConfirmTransactionViewState if (context.mounted) { if (widget.onSuccessInsteadOfRouteOnSuccess == null) { - Navigator.of( - context, - ).popUntil(ModalRoute.withName(routeOnSuccessName)); + Navigator.of(context) + .popUntil(ModalRoute.withName(routeOnSuccessName)); } else { widget.onSuccessInsteadOfRouteOnSuccess!.call(); } @@ -563,9 +560,9 @@ class _ConfirmTransactionViewState child: Text( "Ok", style: STextStyles.button(context).copyWith( - color: Theme.of( - context, - ).extension()!.accentColorDark, + color: Theme.of(context) + .extension()! + .accentColorDark, ), ), onPressed: () { @@ -673,13 +670,13 @@ class _ConfirmTransactionViewState condition: !isDesktop, builder: (child) => Background( child: Scaffold( - backgroundColor: Theme.of( - context, - ).extension()!.background, + backgroundColor: Theme.of(context) + .extension()! + .background, appBar: AppBar( - backgroundColor: Theme.of( - context, - ).extension()!.background, + backgroundColor: Theme.of(context) + .extension()! + .background, leading: AppBarBackButton( onPressed: () async { // if (FocusScope.of(context).hasFocus) { @@ -779,6 +776,25 @@ class _ConfirmTransactionViewState ], ), ), + if (widget.txData.openAliasRecipient != null) ...[ + const SizedBox(height: 12), + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "OpenAlias", + style: STextStyles.smallMed12(context), + ), + const SizedBox(height: 4), + Text( + widget.txData.openAliasRecipient!.domain, + style: STextStyles.itemSubtitle12(context), + ), + ], + ), + ), + ], const SizedBox(height: 12), RoundedWhiteContainer( child: Row( @@ -913,18 +929,18 @@ class _ConfirmTransactionViewState ), child: RoundedWhiteContainer( padding: const EdgeInsets.all(0), - borderColor: Theme.of( - context, - ).extension()!.background, + borderColor: Theme.of(context) + .extension()! + .background, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container( decoration: BoxDecoration( - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, borderRadius: BorderRadius.only( topLeft: Radius.circular( Constants.size.circularBorderRadius, @@ -1091,9 +1107,9 @@ class _ConfirmTransactionViewState ), Container( height: 1, - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, ), Padding( padding: const EdgeInsets.all(12), @@ -1128,20 +1144,55 @@ class _ConfirmTransactionViewState STextStyles.desktopTextExtraExtraSmall( context, ).copyWith( - color: Theme.of( - context, - ).extension()!.textDark, + color: Theme.of(context) + .extension()! + .textDark, ), ), ], ), ), + if (widget.txData.openAliasRecipient != null) ...[ + Container( + height: 1, + color: Theme.of(context) + .extension()! + .background, + ), + Padding( + padding: const EdgeInsets.all(12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "OpenAlias", + style: STextStyles.desktopTextExtraExtraSmall( + context, + ), + ), + const SizedBox(height: 2), + SelectableText( + widget.txData.openAliasRecipient!.domain, + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of(context) + .extension()! + .textDark, + ), + ), + ], + ), + ), + ], if (widget.isPaynymTransaction) Container( height: 1, - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, ), if (widget.isPaynymTransaction) Padding( @@ -1163,9 +1214,9 @@ class _ConfirmTransactionViewState STextStyles.desktopTextExtraExtraSmall( context, ).copyWith( - color: Theme.of( - context, - ).extension()!.textDark, + color: Theme.of(context) + .extension()! + .textDark, ), ), ], @@ -1174,9 +1225,9 @@ class _ConfirmTransactionViewState if (coin is Ethereum) Container( height: 1, - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, ), if (coin is Ethereum) Padding( @@ -1198,9 +1249,9 @@ class _ConfirmTransactionViewState STextStyles.desktopTextExtraExtraSmall( context, ).copyWith( - color: Theme.of( - context, - ).extension()!.textDark, + color: Theme.of(context) + .extension()! + .textDark, ), ), ], @@ -1331,9 +1382,9 @@ class _ConfirmTransactionViewState focusNode: _noteFocusNode, style: STextStyles.desktopTextExtraSmall(context) .copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, + color: Theme.of(context) + .extension()! + .textFieldActiveText, height: 1.8, ), onChanged: (_) => setState(() {}), @@ -1393,9 +1444,9 @@ class _ConfirmTransactionViewState horizontal: 16, vertical: 18, ), - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of(context) + .extension()! + .textFieldDefaultBG, child: SelectableText( ref.watch(pAmountFormatter(coin)).format(fee!), style: STextStyles.itemSubtitle(context), @@ -1424,9 +1475,9 @@ class _ConfirmTransactionViewState horizontal: 16, vertical: 18, ), - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of(context) + .extension()! + .textFieldDefaultBG, child: SelectableText( "~${fee!.raw.toInt() ~/ widget.txData.vSize!}", style: STextStyles.itemSubtitle(context), @@ -1444,22 +1495,21 @@ class _ConfirmTransactionViewState padding: isDesktop ? const EdgeInsets.symmetric(horizontal: 16, vertical: 18) : const EdgeInsets.all(12), - color: Theme.of( - context, - ).extension()!.snackBarBackSuccess, + color: Theme.of(context) + .extension()! + .snackBarBackSuccess, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( isDesktop ? "Total amount to send" : "Total amount", style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context) - .extension()! - .textConfirmTotalAmount, - ) + ? STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ) : STextStyles.titleBold12(context).copyWith( color: Theme.of(context) .extension()! @@ -1471,13 +1521,12 @@ class _ConfirmTransactionViewState .watch(pAmountFormatter(coin)) .format(amountWithoutChange + fee!), style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context) - .extension()! - .textConfirmTotalAmount, - ) + ? STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ) : STextStyles.itemSubtitle12(context).copyWith( color: Theme.of(context) .extension()! @@ -1495,16 +1544,16 @@ class _ConfirmTransactionViewState ? const EdgeInsets.symmetric(horizontal: 32, vertical: 8) : const EdgeInsets.symmetric(vertical: 8), child: RoundedContainer( - color: Theme.of( - context, - ).extension()!.warningBackground, + color: Theme.of(context) + .extension()! + .warningBackground, child: Row( children: [ Icon( Icons.warning_amber_rounded, - color: Theme.of( - context, - ).extension()!.warningForeground, + color: Theme.of(context) + .extension()! + .warningForeground, size: 20, ), const SizedBox(width: 8), @@ -1513,9 +1562,9 @@ class _ConfirmTransactionViewState "This transaction spends a UTXO containing " "an ordinal inscription.", style: STextStyles.smallMed12(context).copyWith( - color: Theme.of( - context, - ).extension()!.warningForeground, + color: Theme.of(context) + .extension()! + .warningForeground, ), ), ), diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 13615a092d..31454e156c 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -29,6 +29,8 @@ import '../../providers/ui/fee_rate_type_state_provider.dart'; import '../../providers/ui/preview_tx_button_state_provider.dart'; import '../../providers/wallet/public_private_balance_state_provider.dart'; import '../../route_generator.dart'; +import '../../services/openalias/open_alias.dart'; +import '../../services/openalias/open_alias_service.dart'; import '../../services/spark_names_service.dart'; import '../../themes/coin_icon_provider.dart'; import '../../themes/stack_colors.dart'; @@ -77,6 +79,7 @@ import '../../widgets/icon_widgets/clipboard_icon.dart'; import '../../widgets/icon_widgets/qrcode_icon.dart'; import '../../widgets/icon_widgets/x_icon.dart'; import '../../widgets/mwc_txs_method_toggle.dart'; +import '../../widgets/open_alias_dialog.dart'; import '../../widgets/rounded_white_container.dart'; import '../../widgets/stack_dialog.dart'; import '../../widgets/stack_text_field.dart'; @@ -124,6 +127,42 @@ class _SendViewState extends ConsumerState { late final CryptoCurrency coin; late final ClipboardInterface clipboard; + OpenAliasRecipient? _openAlias; + OpenAliasRecipient? get _acceptedOpenAlias => + _openAlias?.address == _address && + _openAlias?.address == sendToController.text + ? _openAlias + : null; + + Future _useOpenAlias() async { + final originalText = sendToController.text; + final result = await showDialog( + context: context, + builder: (_) => OpenAliasDialog( + initialInput: originalText.contains('.') || originalText.contains('@') + ? originalText + : '', + resolve: (input) => OpenAliasService().resolve( + input, + validateAddress: coin.validateAddress, + ), + ), + ); + if (!mounted || + result == null || + widget.walletId != walletId || + sendToController.text != originalText) { + return; + } + setState(() { + _address = result.address; + sendToController.text = result.address; + _openAlias = result; + _addressToggleFlag = true; + _setValidAddressProviders(result.address); + }); + } + late TextEditingController sendToController; late TextEditingController cryptoAmountController; late TextEditingController baseAmountController; @@ -1077,6 +1116,7 @@ class _SendViewState extends ConsumerState { final memo = coin is Stellar ? memoController.text : null; txDataFuture = wallet.prepareSend( txData: TxData( + openAliasRecipient: _acceptedOpenAlias, recipients: [ TxRecipient( address: _address!, @@ -1181,6 +1221,7 @@ class _SendViewState extends ConsumerState { } void clearSendForm() { + _openAlias = null; if (!mounted) { return; } @@ -1305,6 +1346,11 @@ class _SendViewState extends ConsumerState { ); sendToController = TextEditingController(); + sendToController.addListener(() { + if (_openAlias != null && sendToController.text != _openAlias!.address) { + setState(() => _openAlias = null); + } + }); cryptoAmountController = TextEditingController(); baseAmountController = TextEditingController(); noteController = TextEditingController(); @@ -1696,21 +1742,11 @@ class _SendViewState extends ConsumerState { style: STextStyles.smallMed12(context), textAlign: TextAlign.left, ), - // if (coin is Monero) - // CustomTextButton( - // text: "Use OpenAlias", - // onTap: () async { - // await showModalBottomSheet( - // context: context, - // builder: (context) => - // OpenAliasBottomSheet( - // onSelected: (address) { - // sendToController.text = address; - // }, - // ), - // ); - // }, - // ), + if (coin is Monero) + CustomTextButton( + text: "Use OpenAlias", + onTap: _useOpenAlias, + ), ], ), if (!isSlatepackMode) const SizedBox(height: 8), @@ -2054,6 +2090,10 @@ class _SendViewState extends ConsumerState { } }, ), + if (_acceptedOpenAlias != null) + OpenAliasAttribution( + recipient: _acceptedOpenAlias!, + ), if (isFiro || isMwebEnabled) const SizedBox(height: 12), if (isFiro || isMwebEnabled) 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 09d2e67979..0dcdcd2899 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart @@ -34,6 +34,8 @@ import '../../../../providers/ui/fee_rate_type_state_provider.dart'; import '../../../../providers/ui/preview_tx_button_state_provider.dart'; import '../../../../providers/wallet/desktop_fee_providers.dart'; import '../../../../providers/wallet/public_private_balance_state_provider.dart'; +import '../../../../services/openalias/open_alias.dart'; +import '../../../../services/openalias/open_alias_service.dart'; import '../../../../services/spark_names_service.dart'; import '../../../../themes/stack_colors.dart'; import '../../../../utilities/address_utils.dart'; @@ -78,6 +80,7 @@ import '../../../../widgets/icon_widgets/clipboard_icon.dart'; import '../../../../widgets/icon_widgets/qrcode_icon.dart'; import '../../../../widgets/icon_widgets/x_icon.dart'; import '../../../../widgets/mwc_txs_method_toggle.dart'; +import '../../../../widgets/open_alias_dialog.dart'; import '../../../../widgets/rounded_container.dart'; import '../../../../widgets/stack_text_field.dart'; import '../../../../widgets/textfield_icon_button.dart'; @@ -110,6 +113,42 @@ class _DesktopSendState extends ConsumerState { late final CryptoCurrency coin; late final ClipboardInterface clipboard; + OpenAliasRecipient? _openAlias; + OpenAliasRecipient? get _acceptedOpenAlias => + _openAlias?.address == _address && + _openAlias?.address == sendToController.text + ? _openAlias + : null; + + Future _useOpenAlias() async { + final originalText = sendToController.text; + final result = await showDialog( + context: context, + builder: (_) => OpenAliasDialog( + initialInput: originalText.contains('.') || originalText.contains('@') + ? originalText + : '', + resolve: (input) => OpenAliasService().resolve( + input, + validateAddress: coin.validateAddress, + ), + ), + ); + if (!mounted || + result == null || + widget.walletId != walletId || + sendToController.text != originalText) { + return; + } + setState(() { + _address = result.address; + sendToController.text = result.address; + _openAlias = result; + _addressToggleFlag = true; + _setValidAddressProviders(result.address); + }); + } + late TextEditingController sendToController; late TextEditingController cryptoAmountController; late TextEditingController baseAmountController; @@ -721,6 +760,7 @@ class _DesktopSendState extends ConsumerState { final memo = hasOptionalMemo ? memoController.text : null; txDataFuture = wallet.prepareSend( txData: TxData( + openAliasRecipient: _acceptedOpenAlias, recipients: [ TxRecipient( address: _address!, @@ -852,6 +892,7 @@ class _DesktopSendState extends ConsumerState { } void clearSendForm() { + _openAlias = null; if (!mounted) { return; } @@ -1262,6 +1303,11 @@ class _DesktopSendState extends ConsumerState { isEpiccash = coin is Epiccash; sendToController = TextEditingController(); + sendToController.addListener(() { + if (_openAlias != null && sendToController.text != _openAlias!.address) { + setState(() => _openAlias = null); + } + }); cryptoAmountController = TextEditingController(); baseAmountController = TextEditingController(); memoController = TextEditingController(); @@ -1781,14 +1827,21 @@ class _DesktopSendState extends ConsumerState { if (!isPaynymSend && !((isMimblewimblecoin || isEpiccash) && ref.watch(pIsSlatepack(widget.walletId)))) - Text( - "Send to", - style: STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of(context) - .extension()! - .textFieldActiveSearchIconRight, - ), - textAlign: TextAlign.left, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Send to", + style: STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldActiveSearchIconRight, + ), + textAlign: TextAlign.left, + ), + if (coin is Monero) + CustomTextButton(text: "Use OpenAlias", onTap: _useOpenAlias), + ], ), if (!isPaynymSend && !((isMimblewimblecoin || isEpiccash) && @@ -2033,6 +2086,8 @@ class _DesktopSendState extends ConsumerState { } }, ), + if (_acceptedOpenAlias != null) + OpenAliasAttribution(recipient: _acceptedOpenAlias!), // OP_RETURN metadata info (green, public mode only, with tooltip) Builder( builder: (context) { diff --git a/lib/wallets/models/tx_data.dart b/lib/wallets/models/tx_data.dart index 1c8f81c931..539f306c8b 100644 --- a/lib/wallets/models/tx_data.dart +++ b/lib/wallets/models/tx_data.dart @@ -7,6 +7,7 @@ import 'package:web3dart/web3dart.dart' as web3dart; import '../../models/input.dart'; import '../../models/isar/models/blockchain_data/v2/transaction_v2.dart'; import '../../models/isar/models/isar_models.dart'; +import '../../services/openalias/open_alias.dart'; import '../../models/paynym/paynym_account_lite.dart'; import '../../utilities/amount/amount.dart'; import '../../utilities/enums/fee_rate_type_enum.dart'; @@ -53,6 +54,12 @@ class TxData { final String? memo; final List? recipients; + final OpenAliasRecipient? _openAliasRecipient; + OpenAliasRecipient? get openAliasRecipient => + recipients?.length == 1 && + recipients!.single.address == _openAliasRecipient?.address + ? _openAliasRecipient + : null; final Set? utxos; final List? usedUTXOs; @@ -130,6 +137,7 @@ class TxData { this.noteOnChain, this.memo, this.recipients, + this._openAliasRecipient, this.utxos, this.usedUTXOs, this.changeAddress, @@ -275,6 +283,7 @@ class TxData { Set? utxos, List? usedUTXOs, List? recipients, + OpenAliasRecipient? openAliasRecipient, String? frostMSConfig, List? frostSigners, String? changeAddress, @@ -329,6 +338,7 @@ class TxData { utxos: utxos ?? this.utxos, usedUTXOs: usedUTXOs ?? this.usedUTXOs, recipients: recipients ?? this.recipients, + openAliasRecipient: openAliasRecipient ?? this.openAliasRecipient, frostMSConfig: frostMSConfig ?? this.frostMSConfig, frostSigners: frostSigners ?? this.frostSigners, changeAddress: changeAddress ?? this.changeAddress, diff --git a/lib/widgets/open_alias_dialog.dart b/lib/widgets/open_alias_dialog.dart new file mode 100644 index 0000000000..e681eebf38 --- /dev/null +++ b/lib/widgets/open_alias_dialog.dart @@ -0,0 +1,283 @@ +import 'package:flutter/material.dart'; + +import '../services/openalias/open_alias.dart'; +import '../themes/stack_colors.dart'; +import '../utilities/constants.dart'; +import '../utilities/text_styles.dart'; +import '../utilities/util.dart'; +import 'conditional_parent.dart'; +import 'desktop/desktop_dialog.dart'; +import 'desktop/desktop_dialog_close_button.dart'; +import 'desktop/primary_button.dart'; +import 'desktop/secondary_button.dart'; +import 'rounded_container.dart'; +import 'stack_dialog.dart'; +import 'stack_text_field.dart'; + +class OpenAliasDialog extends StatefulWidget { + const OpenAliasDialog({ + super.key, + required this.resolve, + this.initialInput = "", + }); + + final Future Function(String) resolve; + final String initialInput; + + @override + State createState() => _OpenAliasDialogState(); +} + +class _OpenAliasDialogState extends State { + late final TextEditingController _inputController; + late final FocusNode _inputFocusNode; + late String _lastInput; + + OpenAliasRecipient? _result; + String? _error; + bool _busy = false; + int _generation = 0; + + void _onInputChanged() { + // Ignore selection-only notifications. + if (_inputController.text == _lastInput) { + return; + } + _lastInput = _inputController.text; + _generation++; + setState(() { + _busy = false; + _result = null; + _error = null; + }); + } + + Future _lookup() async { + if (_busy || _inputController.text.trim().isEmpty) { + return; + } + final generation = ++_generation; + setState(() { + _busy = true; + _result = null; + _error = null; + }); + try { + final result = await widget.resolve(_inputController.text); + if (mounted && generation == _generation) { + setState(() => _result = result); + } + } catch (e) { + if (mounted && generation == _generation) { + setState( + () => _error = e is OpenAliasException + ? e.message + : "Lookup failed. Please try again.", + ); + } + } finally { + if (mounted && generation == _generation) { + setState(() => _busy = false); + } + } + } + + @override + void initState() { + _inputController = TextEditingController(text: widget.initialInput); + _inputFocusNode = FocusNode(); + _lastInput = _inputController.text; + _inputController.addListener(_onInputChanged); + + super.initState(); + } + + @override + void dispose() { + _generation++; + _inputController.dispose(); + _inputFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + final colors = Theme.of(context).extension()!; + final result = _result; + + final labelStyle = isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.smallMed12(context); + final valueStyle = isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith(color: colors.textDark) + : STextStyles.itemSubtitle12(context); + + return ConditionalParent( + condition: isDesktop, + builder: (child) => DesktopDialog( + maxWidth: 580, + maxHeight: double.infinity, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "Use OpenAlias", + style: STextStyles.desktopH3(context), + ), + ), + const DesktopDialogCloseButton(), + ], + ), + Padding( + padding: const EdgeInsets.only(left: 32, right: 32, bottom: 32), + child: child, + ), + ], + ), + ), + child: ConditionalParent( + condition: !isDesktop, + builder: (child) => StackDialogBase( + keyboardPaddingAmount: MediaQuery.of(context).viewInsets.bottom, + child: child, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (!isDesktop) + Text("Use OpenAlias", style: STextStyles.pageTitleH2(context)), + if (!isDesktop) const SizedBox(height: 16), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: const Key("openAliasInput"), + controller: _inputController, + focusNode: _inputFocusNode, + autofocus: isDesktop, + autocorrect: false, + enableSuggestions: false, + keyboardType: TextInputType.emailAddress, + style: isDesktop + ? STextStyles.desktopTextExtraSmall( + context, + ).copyWith(color: colors.textFieldActiveText, height: 1.8) + : STextStyles.field(context), + decoration: standardInputDecoration( + "Domain or email-style alias", + _inputFocusNode, + context, + desktopMed: isDesktop, + ), + onSubmitted: (_) => _lookup(), + ), + ), + if (_error != null) + Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.only(left: 12.0, top: 4.0), + child: Semantics( + liveRegion: true, + child: Text( + _error!, + textAlign: TextAlign.left, + style: STextStyles.label( + context, + ).copyWith(color: colors.textError), + ), + ), + ), + ), + if (result != null) ...[ + const SizedBox(height: 16), + RoundedContainer( + color: colors.textFieldDefaultBG, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("OpenAlias", style: labelStyle), + const SizedBox(height: 4), + SelectableText(result.domain, style: valueStyle), + const SizedBox(height: 12), + Text("Address", style: labelStyle), + const SizedBox(height: 4), + SelectableText( + result.address, + key: const Key("openAliasResolvedAddress"), + style: valueStyle, + ), + ], + ), + ), + const SizedBox(height: 12), + Text( + "Check the recipient and full address before continuing.", + style: labelStyle, + ), + ], + SizedBox(height: isDesktop ? 32 : 24), + Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Cancel", + buttonHeight: isDesktop ? ButtonHeight.l : null, + onPressed: Navigator.of(context).pop, + ), + ), + const SizedBox(width: 16), + Expanded( + child: result == null + ? PrimaryButton( + label: _busy ? "Looking up..." : "Look up", + buttonHeight: isDesktop ? ButtonHeight.l : null, + enabled: !_busy, + onPressed: _lookup, + ) + : PrimaryButton( + key: const Key("acceptOpenAlias"), + label: "Use address", + buttonHeight: isDesktop ? ButtonHeight.l : null, + onPressed: () => Navigator.of(context).pop(result), + ), + ), + ], + ), + ], + ), + ), + ); + } +} + +class OpenAliasAttribution extends StatelessWidget { + const OpenAliasAttribution({super.key, required this.recipient}); + + final OpenAliasRecipient recipient; + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.only(left: 12.0, top: 4.0), + child: Text( + "Resolved from OpenAlias ${recipient.domain}", + textAlign: TextAlign.left, + style: STextStyles.label(context).copyWith( + color: Theme.of(context).extension()!.accentColorGreen, + ), + ), + ), + ); + } +} From d4483e77e81aaabe0d63a63103ec694c9ada9642 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 29 Sep 2026 10:10:47 -0500 Subject: [PATCH 04/17] test(openalias): cover OpenAlias lookup and send integration Record parsing, DoH response validation and transport, the lookup service, the lookup dialog and the TxData recipient. The live lookup test is skipped by default. --- .../openalias/doh_open_alias_test.dart | 104 +++++++++++ .../openalias/doh_transport_test.dart | 89 +++++++++ .../openalias/open_alias_live_test.dart | 35 ++++ .../openalias/open_alias_service_test.dart | 65 +++++++ test/services/openalias/open_alias_test.dart | 81 ++++++++ .../open_alias_transaction_test.dart | 36 ++++ test/widgets/open_alias_dialog_test.dart | 175 ++++++++++++++++++ 7 files changed, 585 insertions(+) create mode 100644 test/services/openalias/doh_open_alias_test.dart create mode 100644 test/services/openalias/doh_transport_test.dart create mode 100644 test/services/openalias/open_alias_live_test.dart create mode 100644 test/services/openalias/open_alias_service_test.dart create mode 100644 test/services/openalias/open_alias_test.dart create mode 100644 test/services/openalias/open_alias_transaction_test.dart create mode 100644 test/widgets/open_alias_dialog_test.dart diff --git a/test/services/openalias/doh_open_alias_test.dart b/test/services/openalias/doh_open_alias_test.dart new file mode 100644 index 0000000000..670b51e47a --- /dev/null +++ b/test/services/openalias/doh_open_alias_test.dart @@ -0,0 +1,104 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/services/openalias/open_alias.dart'; +import 'package:stackwallet/services/openalias/doh_open_alias.dart'; + +Map reply({ + bool ad = true, + List>? answers, +}) => { + 'Status': 0, + 'AD': ad, + 'CD': false, + 'TC': false, + 'Question': [ + {'name': 'alice.example.', 'type': 16}, + ], + 'Answer': + answers ?? [txt('alice.example.', 'oa1:xmr recipient_address=valid;')], +}; +Map txt(String name, String data) => { + 'name': name, + 'type': 16, + 'TTL': 60, + 'data': data, +}; +OpenAliasRecipient select(List records) => selectOpenAliasRecipient( + domain: 'alice.example', + records: records, + validateAddress: (s) => s == 'valid' || s == 'second', +); + +void main() { + test('joins TXT chunks within one record, with presentation escapes', () { + expect( + decodeDnsTxt(r'"oa1:xmr recipient_address=va" "lid;"'), + 'oa1:xmr recipient_address=valid;', + ); + expect(decodeDnsTxt(r'"a\032b\"c"'), 'a b"c'); + expect( + decodeDnsTxt('oa1:xmr recipient_address=valid;'), + 'oa1:xmr recipient_address=valid;', + ); + }); + test('malformed TXT presentation cannot become an address', () { + for (final input in ['"unterminated', '"one" junk', r'"\999"']) { + expect(() => decodeDnsTxt(input), throwsA(isA())); + } + }); + test( + 'accepts only authenticated, complete answers to the exact question', + () { + expect(decodeAuthenticatedDns(reply(), 'alice.example'), hasLength(1)); + for (final altered in [ + reply(ad: false), + reply()..remove('AD'), + reply()..['CD'] = true, + reply()..['TC'] = true, + reply()..['Status'] = 2, + reply() + ..['Question'] = [ + {'name': 'attacker.example.', 'type': 16}, + ], + ]) { + expect( + () => decodeAuthenticatedDns(altered, 'alice.example'), + throwsA(isA()), + ); + } + }, + ); + test('follows authenticated CNAMEs and ignores unrelated TXT answers', () { + final records = decodeAuthenticatedDns( + reply( + answers: [ + { + 'name': 'alice.example.', + 'type': 5, + 'TTL': 60, + 'data': 'pay.example.', + }, + txt('pay.example.', 'oa1:xmr recipient_address=valid;'), + txt('attacker.example.', 'oa1:xmr recipient_address=second;'), + ], + ), + 'alice.example', + ); + expect(select(records).address, 'valid'); + }); + test('rejects CNAME loops and CNAME/TXT ambiguity', () { + for (final answers in [ + [ + {'name': 'alice.example.', 'type': 5, 'data': 'alice.example.'}, + ], + [ + {'name': 'alice.example.', 'type': 5, 'data': 'pay.example.'}, + txt('alice.example.', 'oa1:xmr recipient_address=valid;'), + ], + ]) { + expect( + () => decodeAuthenticatedDns(reply(answers: answers), 'alice.example'), + throwsA(isA()), + ); + } + }); +} diff --git a/test/services/openalias/doh_transport_test.dart b/test/services/openalias/doh_transport_test.dart new file mode 100644 index 0000000000..6db90added --- /dev/null +++ b/test/services/openalias/doh_transport_test.dart @@ -0,0 +1,89 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/services/openalias/dns_proxy_connection.dart'; +import 'package:stackwallet/services/openalias/doh_open_alias.dart'; +import 'package:stackwallet/services/openalias/open_alias.dart'; + +void main() { + test('cancelling a stalled SOCKS handshake closes the socket', () async { + final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final greeted = Completer(); + final disconnected = Completer(); + Socket? peer; + final sub = server.listen((socket) { + peer = socket; + socket.listen((_) { + if (!greeted.isCompleted) greeted.complete(); + }, onDone: () => disconnected.complete()); + }); + final task = DnsProxyConnection(InternetAddress.loopbackIPv4, server.port); + final failed = expectLater(task.socket, throwsA(isA())); + try { + await greeted.future.timeout(const Duration(seconds: 2)); + task.cancel(); + await failed; + await disconnected.future.timeout(const Duration(seconds: 2)); + } finally { + task.cancel(); + peer?.destroy(); + await sub.cancel(); + await server.close(); + } + }); + + test( + 'SOCKS connects to a numeric endpoint without resolving dns.google locally', + () async { + final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final requested = Completer>(); + final sockets = []; + final sub = server.listen((socket) { + sockets.add(socket); + final bytes = []; + var greeted = false; + socket.listen((data) { + bytes.addAll(data); + if (!greeted && bytes.length >= 3) { + expect(bytes.sublist(0, 3), [5, 1, 0]); + bytes.removeRange(0, 3); + greeted = true; + socket.add([5, 0]); + } + if (greeted && bytes.length >= 10 && !requested.isCompleted) { + requested.complete(bytes.sublist(0, 10)); + socket.add([5, 5, 0, 1, 0, 0, 0, 0, 0, 0]); + } + }); + }); + try { + await expectLater( + DohOpenAlias().lookup( + 'alice.example', + proxyInfo: (host: InternetAddress.loopbackIPv4, port: server.port), + ), + throwsA(isA()), + ); + expect(await requested.future.timeout(const Duration(seconds: 2)), [ + 5, + 1, + 0, + 1, + 8, + 8, + 8, + 8, + 1, + 187, + ]); + } finally { + for (final socket in sockets) { + socket.destroy(); + } + await sub.cancel(); + await server.close(); + } + }, + ); +} diff --git a/test/services/openalias/open_alias_live_test.dart b/test/services/openalias/open_alias_live_test.dart new file mode 100644 index 0000000000..54bc3f2fe5 --- /dev/null +++ b/test/services/openalias/open_alias_live_test.dart @@ -0,0 +1,35 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/services/openalias/open_alias.dart'; +import 'package:stackwallet/services/openalias/open_alias_service.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; + +void main() { + final skip = Platform.environment['OPENALIAS_LIVE_TESTS'] != '1'; + final service = OpenAliasService( + externalCalls: () => true, + useTor: () => false, + ); + final validate = Monero(CryptoCurrencyNetwork.main).validateAddress; + test( + 'live signed alias resolves to a valid Monero mainnet address', + () async { + final result = await service.resolve( + 'donate.getmonero.org', + validateAddress: validate, + ); + expect(result.domain, 'donate.getmonero.org'); + expect(validate(result.address), isTrue); + }, + skip: skip, + ); + for (final domain in ['openalias.org', 'dnssec-failed.org']) { + test('live unsigned or bogus DNS is rejected: $domain', () async { + await expectLater( + service.resolve(domain, validateAddress: validate), + throwsA(isA()), + ); + }, skip: skip); + } +} diff --git a/test/services/openalias/open_alias_service_test.dart b/test/services/openalias/open_alias_service_test.dart new file mode 100644 index 0000000000..2d9c97de60 --- /dev/null +++ b/test/services/openalias/open_alias_service_test.dart @@ -0,0 +1,65 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/services/openalias/open_alias.dart'; +import 'package:stackwallet/services/openalias/open_alias_service.dart'; + +void main() { + test('privacy restrictions prevent any network lookup', () async { + var calls = 0; + for (final policy in [(false, false, true), (true, true, false)]) { + final service = OpenAliasService( + externalCalls: () => policy.$1, + useTor: () => policy.$2, + supportsTor: () => policy.$3, + lookup: (_, _) async { + calls++; + return []; + }, + ); + await expectLater( + service.resolve('alice.example', validateAddress: (_) => true), + throwsA(isA()), + ); + } + expect(calls, 0); + }); + test( + 'Tor policy is passed to the lookup and a changed policy rejects results', + () async { + var tor = true; + final pending = Completer>(); + final service = OpenAliasService( + externalCalls: () => true, + useTor: () => tor, + supportsTor: () => true, + lookup: (domain, useTor) { + expect(domain, 'alice.example'); + expect(useTor, isTrue); + return pending.future; + }, + ); + final future = service.resolve( + 'alice@example', + validateAddress: (_) => true, + ); + tor = false; + pending.complete(['oa1:xmr recipient_address=valid;']); + await expectLater(future, throwsA(isA())); + }, + ); + test('returns only a validated literal recipient', () async { + final service = OpenAliasService( + externalCalls: () => true, + useTor: () => false, + supportsTor: () => true, + lookup: (_, _) async => ['oa1:xmr recipient_address=valid;'], + ); + final result = await service.resolve( + 'alice.example', + validateAddress: (s) => s == 'valid', + ); + expect(result.address, 'valid'); + expect(result.domain, 'alice.example'); + }); +} diff --git a/test/services/openalias/open_alias_test.dart b/test/services/openalias/open_alias_test.dart new file mode 100644 index 0000000000..279c17a12a --- /dev/null +++ b/test/services/openalias/open_alias_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/services/openalias/open_alias.dart'; + +OpenAliasRecipient select(List records) => selectOpenAliasRecipient( + domain: 'alice.example', + records: records, + validateAddress: (s) => s == 'valid' || s == 'second', +); + +void main() { + test('normalizes email aliases, case and root dot', () { + expect(normalizeOpenAlias(' Alice@Example. '), 'alice.example'); + expect( + normalizeOpenAlias('xn--bcher-kva.example'), + 'xn--bcher-kva.example', + ); + }); + for (final name in [ + 'example', + 'https://example.org', + 'a@@b.org', + 'a..org', + 'a.org/path', + '-a.org', + 'ü.example', + 'K.example', + '127.0.0.1', + 'a.onion', + 'a.org\u0000', + ]) { + test('rejects invalid alias $name', () { + expect( + () => normalizeOpenAlias(name), + throwsA(isA()), + ); + }); + } + test( + 'parses semicolon fields and quoted values without requiring spaces', + () { + expect( + select([ + 'oa1:xmr recipient_name="Alice; Example";recipient_address="valid";', + ]).address, + 'valid', + ); + }, + ); + test('ignores other coins and nonmatching currency prefixes', () { + expect( + select([ + 'oa1:btc recipient_address=bad;', + 'oa1:xmrfoo recipient_address=bad;', + 'oa1:xmr recipient_address=valid;', + ]).address, + 'valid', + ); + }); + for (final records in [ + [], + ['oa1:xmr recipient_address=wrong-network;'], + ['oa1:xmr recipient_address=valid;recipient_address=second;'], + ['oa1:xmr recipient_address=valid;', 'oa1:xmr recipient_address=second;'], + ['oa1:xmr recipient_address=valid;tx_payment_id=0123456789abcdef;'], + ['oa1:xmr recipient_address="valid;'], + ]) { + test( + 'rejects missing, invalid or ambiguous payment instructions $records', + () { + expect(() => select(records), throwsA(isA())); + }, + ); + } + test('does not apply suggested amounts or names to recipient', () { + final result = select([ + 'oa1:xmr recipient_address=valid;tx_amount=999;recipient_name=Someone;', + ]); + expect(result.domain, 'alice.example'); + expect(result.address, 'valid'); + }); +} diff --git a/test/services/openalias/open_alias_transaction_test.dart b/test/services/openalias/open_alias_transaction_test.dart new file mode 100644 index 0000000000..700df3e64c --- /dev/null +++ b/test/services/openalias/open_alias_transaction_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/services/openalias/open_alias.dart'; +import 'package:stackwallet/wallets/models/tx_data.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/address.dart'; +import 'package:stackwallet/utilities/amount/amount.dart'; + +void main() { + test('fee updates preserve the alias; recipient changes invalidate it', () { + const alias = OpenAliasRecipient( + domain: 'alice.example', + address: 'accepted', + ); + final recipient = TxRecipient( + address: 'accepted', + amount: Amount(rawValue: BigInt.one, fractionDigits: 12), + isChange: false, + addressType: AddressType.cryptonote, + ); + final prepared = TxData( + recipients: [recipient], + openAliasRecipient: alias, + ).copyWith(fee: Amount(rawValue: BigInt.two, fractionDigits: 12)); + expect(prepared.recipients!.single.address, 'accepted'); + expect(prepared.openAliasRecipient, same(alias)); + expect( + prepared + .copyWith(recipients: [recipient.copyWith(address: 'different')]) + .openAliasRecipient, + isNull, + ); + expect( + prepared.copyWith(recipients: [recipient, recipient]).openAliasRecipient, + isNull, + ); + }); +} diff --git a/test/widgets/open_alias_dialog_test.dart b/test/widgets/open_alias_dialog_test.dart new file mode 100644 index 0000000000..bc0fc524fb --- /dev/null +++ b/test/widgets/open_alias_dialog_test.dart @@ -0,0 +1,175 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/services/openalias/open_alias.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/widgets/open_alias_dialog.dart'; + +import '../sample_data/theme_json.dart'; + +const recipient = OpenAliasRecipient( + domain: 'alice.example', + address: 'full-monero-address', +); + +Future open( + WidgetTester tester, + Future Function(String) resolve, + void Function(OpenAliasRecipient?) accepted, +) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + extensions: [ + StackColors.fromStackColorTheme( + StackTheme.fromJson(json: lightThemeJsonMap), + ), + ], + ), + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () async { + accepted( + await showDialog( + context: context, + builder: (_) => OpenAliasDialog(resolve: resolve), + ), + ); + }, + child: const Text('Open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); +} + +void main() { + for (final size in [const Size(390, 844), const Size(1280, 900)]) { + testWidgets('explicit lookup and full address acceptance at $size', ( + tester, + ) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + // Util.isDesktop is false on Linux below 800 logical pixels. + Util.screenWidth = size.width; + addTearDown(() => Util.screenWidth = null); + var calls = 0; + OpenAliasRecipient? accepted; + await open(tester, (_) async { + calls++; + return recipient; + }, (r) => accepted = r); + await tester.enterText( + find.byKey(const Key('openAliasInput')), + 'alice.example', + ); + await tester.pump(); + expect(calls, 0); + await tester.tap(find.text('Look up')); + await tester.pumpAndSettle(); + expect(find.text('full-monero-address'), findsOneWidget); + expect(accepted, isNull); + await tester.tap(find.text('Use address')); + await tester.pumpAndSettle(); + expect(accepted, same(recipient)); + expect(calls, 1); + expect(tester.takeException(), isNull); + }); + } + testWidgets('edits discard late results and prevent acceptance', ( + tester, + ) async { + final pending = Completer(); + await open(tester, (_) => pending.future, (_) {}); + await tester.enterText( + find.byKey(const Key('openAliasInput')), + 'alice.example', + ); + await tester.tap(find.text('Look up')); + await tester.pump(); + await tester.enterText( + find.byKey(const Key('openAliasInput')), + 'bob.example', + ); + pending.complete(recipient); + await tester.pumpAndSettle(); + expect(find.text('Use address'), findsNothing); + expect(find.text('full-monero-address'), findsNothing); + }); + testWidgets('editing a verified alias invalidates acceptance', ( + tester, + ) async { + await open(tester, (_) async => recipient, (_) {}); + await tester.enterText( + find.byKey(const Key('openAliasInput')), + 'alice.example', + ); + await tester.tap(find.text('Look up')); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const Key('openAliasInput')), + 'bob.example', + ); + await tester.pump(); + expect(find.text('Use address'), findsNothing); + }); + testWidgets('moving the cursor does not discard a verified result', ( + tester, + ) async { + await open(tester, (_) async => recipient, (_) {}); + await tester.enterText( + find.byKey(const Key('openAliasInput')), + 'alice.example', + ); + await tester.tap(find.text('Look up')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('openAliasInput'))); + await tester.pump(); + expect(find.text('Use address'), findsOneWidget); + }); + testWidgets('failed verification remains visible without an accept action', ( + tester, + ) async { + await open( + tester, + (_) async => throw const OpenAliasException('DNSSEC failed'), + (_) {}, + ); + await tester.enterText( + find.byKey(const Key('openAliasInput')), + 'alice.example', + ); + await tester.tap(find.text('Look up')); + await tester.pumpAndSettle(); + expect(find.text('DNSSEC failed'), findsOneWidget); + expect(find.text('Use address'), findsNothing); + }); + testWidgets('cancel during lookup does not update a disposed dialog', ( + tester, + ) async { + final pending = Completer(); + OpenAliasRecipient? accepted; + await open(tester, (_) => pending.future, (r) => accepted = r); + await tester.enterText( + find.byKey(const Key('openAliasInput')), + 'alice.example', + ); + await tester.tap(find.text('Look up')); + await tester.pump(); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + pending.complete(recipient); + await tester.pumpAndSettle(); + expect(accepted, isNull); + expect(tester.takeException(), isNull); + }); +} From d25cee794901fb7e82267c4a26f1ebc197386795 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 1 Oct 2026 10:41:46 -0500 Subject: [PATCH 05/17] fix(openalias): cancel the transport during TLS negotiation Retain the RawSocket across SOCKS negotiation and the TLS upgrade so a lookup timeout can close the live connection before TLS completes. Expose an independently completed result because closing a raw transport during TLS can leave Dart's handshake future pending. Keep cancellation idempotent and dispose of sockets that complete after cancellation. Adapt raw sockets to the Socket/SecureSocket interfaces expected by HttpClient, preserving stream backpressure, partial writes, certificate inspection, dns.google verification and the numeric SOCKS destination. Add regression tests that wait for ClientHello before cancellation and assert peer disconnection before teardown, including repeated attempts and the full lookup timeout. Cover immediate cancellation, a trusted local TLS/HTTP exchange, and rejection of an untrusted certificate. The committed certificate and private key are test-only fixtures, trusted only through an isolated SecurityContext. Validation: all 37 offline OpenAlias tests pass (3 opt-in live tests skipped); static analysis of the changed Dart files reports no issues. Remove the socks5_proxy comment from DnsProxyConnection and preserve its rationale here: socks5_proxy resolves hostnames locally before CONNECT, so the OpenAlias tunnel uses the numeric endpoint 8.8.8.8:443 instead. TLS still verifies dns.google without performing a local hostname lookup. --- .../openalias/dns_proxy_connection.dart | 76 +++-- lib/services/openalias/dns_proxy_socket.dart | 209 ++++++++++++++ .../openalias/doh_transport_test.dart | 269 ++++++++++++++++++ .../fixtures/dns_google_test_cert.pem | 19 ++ .../fixtures/dns_google_test_key.pem | 28 ++ 5 files changed, 584 insertions(+), 17 deletions(-) create mode 100644 lib/services/openalias/dns_proxy_socket.dart create mode 100644 test/services/openalias/fixtures/dns_google_test_cert.pem create mode 100644 test/services/openalias/fixtures/dns_google_test_key.pem diff --git a/lib/services/openalias/dns_proxy_connection.dart b/lib/services/openalias/dns_proxy_connection.dart index 0cb57b204c..c8760226d3 100644 --- a/lib/services/openalias/dns_proxy_connection.dart +++ b/lib/services/openalias/dns_proxy_connection.dart @@ -1,29 +1,54 @@ +import 'dart:async'; import 'dart:io'; import 'package:async/async.dart'; -// socks5_proxy resolves hostnames locally before CONNECT, so tunnel to the -// numeric endpoint instead. +import 'dns_proxy_socket.dart'; + class DnsProxyConnection { - Socket? _transport; + RawSocket? _transport; + DnsProxySocket? _socket; + final _result = Completer(); bool _cancelled = false; - late final Future socket; + Future get socket => _result.future; - DnsProxyConnection(InternetAddress proxyHost, int proxyPort) { - socket = _connect(proxyHost, proxyPort); + DnsProxyConnection( + InternetAddress proxyHost, + int proxyPort, { + SecurityContext? context, + }) { + unawaited( + _connect(proxyHost, proxyPort, context).then( + (socket) { + if (_cancelled) { + socket.destroy(); + } else if (!_result.isCompleted) { + _result.complete(socket); + } + }, + onError: (Object error, StackTrace stack) { + if (!_result.isCompleted) _result.completeError(error, stack); + cancel(); + }, + ), + ); } - Future _connect(InternetAddress host, int port) async { + Future _connect( + InternetAddress host, + int port, + SecurityContext? context, + ) async { try { - final raw = await Socket.connect( + final raw = await RawSocket.connect( host, port, timeout: const Duration(seconds: 5), ); _transport = raw; if (_cancelled) throw const SocketException('DNS connection cancelled'); - // SecureSocket.secure takes over this reader's subscription. - final reader = ChunkedStreamReader(raw); + final transport = _socket = DnsProxySocket(raw); + final reader = ChunkedStreamReader(transport); Future> read(int count) async { final bytes = await reader.readBytes(count); if (bytes.length != count) { @@ -32,12 +57,12 @@ class DnsProxyConnection { return bytes; } - raw.add([5, 1, 0]); + transport.add([5, 1, 0]); final greeting = await read(2); if (greeting[0] != 5 || greeting[1] != 0) { throw const SocketException('SOCKS authentication failed'); } - raw.add([5, 1, 0, 1, 8, 8, 8, 8, 1, 187]); // CONNECT 8.8.8.8:443. + transport.add([5, 1, 0, 1, 8, 8, 8, 8, 1, 187]); // CONNECT 8.8.8.8:443. final reply = await read(4); if (reply[0] != 5 || reply[1] != 0 || reply[2] != 0) { throw const SocketException('SOCKS connection failed'); @@ -53,18 +78,35 @@ class DnsProxyConnection { throw const SocketException('Invalid SOCKS address type'); } await read(2); - final secured = await SecureSocket.secure(raw, host: 'dns.google'); - _transport = secured; + final subscription = await transport.detachForTls(); + await reader.cancel(); + if (_cancelled) throw const SocketException('DNS connection cancelled'); + final secured = await RawSecureSocket.secure( + raw, + subscription: subscription, + host: 'dns.google', + context: context, + ); + _socket = DnsProxySocket.secure(secured); if (_cancelled) throw const SocketException('DNS connection cancelled'); - return secured; + return _socket!; } catch (_) { - cancel(); + _socket?.destroy(); + final transport = _transport; + if (transport != null) unawaited(transport.close()); rethrow; } } void cancel() { + if (_cancelled) return; _cancelled = true; - _transport?.destroy(); + if (!_result.isCompleted) { + _result.completeError(const SocketException('DNS connection cancelled')); + } + _socket?.destroy(); + // This raw socket remains valid even while TLS owns its subscription. + final transport = _transport; + if (transport != null) unawaited(transport.close()); } } diff --git a/lib/services/openalias/dns_proxy_socket.dart b/lib/services/openalias/dns_proxy_socket.dart new file mode 100644 index 0000000000..1b88198aea --- /dev/null +++ b/lib/services/openalias/dns_proxy_socket.dart @@ -0,0 +1,209 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +// Adapts raw transports to HttpClient without giving up the cancellation handle +// during a TLS upgrade. Only detachForTls may transfer the raw subscription. +class DnsProxySocket extends Stream implements Socket { + final RawSocket _raw; + final _input = StreamController(); + final _closed = Completer(); + late final StreamSubscription _subscription; + late final _SocketConsumer _consumer; + late final IOSink _sink; + Completer? _writable; + bool _destroyed = false; + bool _detached = false; + Object? _error; + + DnsProxySocket(this._raw) { + _raw.readEventsEnabled = false; + _raw.writeEventsEnabled = false; + _input.onListen = () => _setReading(true); + _input.onPause = () => _setReading(false); + _input.onResume = () => _setReading(true); + _input.onCancel = () { + if (!_detached && !_destroyed) { + _raw.shutdown(SocketDirection.receive); + } + }; + _consumer = _SocketConsumer(this); + _sink = IOSink(_consumer); + // Errors are still available to flush/close callers; a remote disconnect + // must not also produce an unhandled error on an unobserved sink future. + _sink.done.ignore(); + _subscription = _raw.listen( + _onEvent, + onError: (Object error, StackTrace stack) { + _error = error; + if (!_input.isClosed) _input.addError(error, stack); + destroy(); + }, + onDone: destroy, + ); + } + + factory DnsProxySocket.secure(RawSecureSocket raw) = _DnsSecureSocket; + + void _setReading(bool enabled) { + if (!_detached && !_destroyed) _raw.readEventsEnabled = enabled; + } + + void _onEvent(RawSocketEvent event) { + if (event == RawSocketEvent.read) { + final bytes = _raw.read(); + if (bytes != null) _input.add(bytes); + } else if (event == RawSocketEvent.write) { + _writable?.complete(); + _writable = null; + } else if (event == RawSocketEvent.readClosed) { + unawaited(_input.close()); + } + } + + Future _write(List bytes) async { + var offset = 0; + while (offset < bytes.length) { + if (_destroyed || _detached) { + throw _error ?? const SocketException('DNS connection closed'); + } + offset += _raw.write(bytes, offset, bytes.length - offset); + if (offset < bytes.length) { + final writable = _writable = Completer(); + _raw.writeEventsEnabled = true; + await writable.future; + } + } + } + + Future _closeWrite() async { + if (!_destroyed && !_detached) _raw.shutdown(SocketDirection.send); + if (!_closed.isCompleted) _closed.complete(); + } + + Future> detachForTls() async { + await flush(); + if (_destroyed) throw const SocketException('DNS connection closed'); + _detached = true; + _raw.readEventsEnabled = false; + await _sink.close(); + unawaited(_input.close()); + return _subscription; + } + + @override + void destroy() { + if (_destroyed || _detached) return; + _destroyed = true; + _writable?.complete(); + _writable = null; + _consumer.stop(); + unawaited(_raw.close()); + unawaited(_subscription.cancel()); + unawaited(_input.close()); + if (!_closed.isCompleted) _closed.complete(); + } + + @override + StreamSubscription listen( + void Function(Uint8List)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) => _input.stream.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + + @override + void add(List data) => _sink.add(data); + @override + void addError(Object error, [StackTrace? stackTrace]) => + throw UnsupportedError('Cannot send errors on sockets'); + @override + Future addStream(Stream> stream) => _sink.addStream(stream); + @override + Future flush() => _sink.flush(); + @override + Future close() => _sink.close(); + @override + Future get done => _closed.future; + @override + Encoding get encoding => _sink.encoding; + @override + set encoding(Encoding value) => _sink.encoding = value; + @override + void write(Object? object) => _sink.write(object); + @override + void writeAll(Iterable objects, [String separator = '']) => + _sink.writeAll(objects, separator); + @override + void writeCharCode(int charCode) => _sink.writeCharCode(charCode); + @override + void writeln([Object? object = '']) => _sink.writeln(object); + @override + InternetAddress get address => _raw.address; + @override + InternetAddress get remoteAddress => _raw.remoteAddress; + @override + int get port => _raw.port; + @override + int get remotePort => _raw.remotePort; + @override + bool setOption(SocketOption option, bool enabled) => + _raw.setOption(option, enabled); + @override + Uint8List getRawOption(RawSocketOption option) => _raw.getRawOption(option); + @override + void setRawOption(RawSocketOption option) => _raw.setRawOption(option); +} + +class _SocketConsumer implements StreamConsumer> { + final DnsProxySocket socket; + StreamIterator>? _iterator; + + _SocketConsumer(this.socket); + + @override + Future addStream(Stream> stream) async { + final iterator = _iterator = StreamIterator(stream); + try { + while (await iterator.moveNext()) { + await socket._write(iterator.current); + } + } finally { + await iterator.cancel(); + _iterator = null; + } + } + + void stop() { + final iterator = _iterator; + if (iterator != null) unawaited(iterator.cancel()); + } + + @override + Future close() => socket._closeWrite(); +} + +class _DnsSecureSocket extends DnsProxySocket implements SecureSocket { + final RawSecureSocket _secure; + + _DnsSecureSocket(this._secure) : super(_secure); + + @override + X509Certificate? get peerCertificate => _secure.peerCertificate; + @override + String? get selectedProtocol => _secure.selectedProtocol; + @override + void renegotiate({ + bool useSessionCache = true, + bool requestClientCertificate = false, + bool requireClientCertificate = false, + }) { + // Like dart:io SecureSocket, renegotiation is not implemented. + } +} diff --git a/test/services/openalias/doh_transport_test.dart b/test/services/openalias/doh_transport_test.dart index 6db90added..93c6f6d6e2 100644 --- a/test/services/openalias/doh_transport_test.dart +++ b/test/services/openalias/doh_transport_test.dart @@ -1,12 +1,147 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; +import 'package:async/async.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:stackwallet/services/openalias/dns_proxy_connection.dart'; import 'package:stackwallet/services/openalias/doh_open_alias.dart'; import 'package:stackwallet/services/openalias/open_alias.dart'; void main() { + test( + 'immediate cancellation completes without leaving a connection', + () async { + final proxy = await _StalledTlsProxy.start(); + addTearDown(proxy.close); + final task = DnsProxyConnection( + InternetAddress.loopbackIPv4, + proxy.server.port, + ); + final failed = expectLater(task.socket, throwsA(isA())); + task.cancel(); + await failed.timeout(const Duration(seconds: 2)); + final peer = await proxy.nextPeer(); + await peer.disconnected.future.timeout(const Duration(seconds: 2)); + }, + ); + + test( + 'TLS transport carries HTTP with backpressure and cancels after success', + () async { + final body = List.generate(256 * 1024, (index) => index % 256); + final served = Completer(); + final disconnected = Completer(); + final proxy = await _TlsSocksProxy.start((socket) async { + final reader = ChunkedStreamReader(socket); + final header = []; + while (!ascii.decode(header).endsWith('\r\n\r\n')) { + header.addAll(await reader.readBytes(1)); + } + expect(ascii.decode(header), contains('POST /resolve HTTP/1.1')); + // Let the sender fill the TLS buffers before consuming its body. + await Future.delayed(const Duration(milliseconds: 20)); + expect(await reader.readBytes(body.length), body); + socket.add( + ascii.encode( + 'HTTP/1.1 200 OK\r\nContent-Length: ${body.length}\r\n\r\n', + ), + ); + socket.add(body); + await socket.flush(); + served.complete(); + expect(await reader.readBytes(1), isEmpty); + disconnected.complete(); + }); + addTearDown(proxy.close); + final context = SecurityContext(withTrustedRoots: false) + ..setTrustedCertificates(_certificate); + final task = DnsProxyConnection( + InternetAddress.loopbackIPv4, + proxy.server.port, + context: context, + ); + addTearDown(task.cancel); + final client = HttpClient() + ..connectionFactory = (uri, _, _) async => + ConnectionTask.fromSocket(task.socket, task.cancel); + addTearDown(() => client.close(force: true)); + final request = await client.postUrl(Uri.https('dns.google', '/resolve')); + request.contentLength = body.length; + request.add(body); + final response = await request.close(); + expect(response.statusCode, 200); + expect(response.certificate, isNotNull); + final received = []; + await for (final chunk in response) { + received.addAll(chunk); + } + expect(received, body); + await served.future.timeout(const Duration(seconds: 2)); + task.cancel(); + task.cancel(); + await disconnected.future.timeout(const Duration(seconds: 2)); + expect(proxy.errors, isEmpty); + }, + ); + + test('TLS still rejects an untrusted certificate', () async { + final proxy = await _TlsSocksProxy.start((socket) async { + await socket.drain(); + }); + addTearDown(proxy.close); + final task = DnsProxyConnection( + InternetAddress.loopbackIPv4, + proxy.server.port, + ); + addTearDown(task.cancel); + await expectLater(task.socket, throwsA(isA())); + }); + + test( + 'cancelling stalled TLS closes the peer and completes the task', + () async { + final proxy = await _StalledTlsProxy.start(); + addTearDown(proxy.close); + for (var attempt = 0; attempt < 3; attempt++) { + final task = DnsProxyConnection( + InternetAddress.loopbackIPv4, + proxy.server.port, + ); + final failed = expectLater( + task.socket, + throwsA(isA()), + ); + addTearDown(task.cancel); + final peer = await proxy.nextPeer(); + await peer.clientHello.future.timeout(const Duration(seconds: 2)); + task.cancel(); + task.cancel(); + await peer.disconnected.future.timeout(const Duration(seconds: 2)); + await failed.timeout(const Duration(seconds: 2)); + } + }, + ); + + test('lookup timeout closes a stalled TLS connection', () async { + final proxy = await _StalledTlsProxy.start(); + addTearDown(proxy.close); + final failed = expectLater( + DohOpenAlias().lookup( + 'alice.example', + proxyInfo: ( + host: InternetAddress.loopbackIPv4, + port: proxy.server.port, + ), + ), + throwsA(isA()), + ); + final peer = await proxy.nextPeer(); + await peer.clientHello.future.timeout(const Duration(seconds: 2)); + await failed.timeout(const Duration(seconds: 8)); + await peer.disconnected.future.timeout(const Duration(seconds: 2)); + }); + test('cancelling a stalled SOCKS handshake closes the socket', () async { final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); final greeted = Completer(); @@ -87,3 +222,137 @@ void main() { }, ); } + +class _StalledTlsProxy { + final ServerSocket server; + final _accepted = StreamController<_StalledTlsPeer>(); + final _peers = <_StalledTlsPeer>[]; + late final StreamIterator<_StalledTlsPeer> _iterator; + late final StreamSubscription _subscription; + + _StalledTlsProxy(this.server) { + _iterator = StreamIterator(_accepted.stream); + _subscription = server.listen((socket) { + final peer = _StalledTlsPeer(socket); + _peers.add(peer); + _accepted.add(peer); + }); + } + + static Future<_StalledTlsProxy> start() async => _StalledTlsProxy( + await ServerSocket.bind(InternetAddress.loopbackIPv4, 0), + ); + + Future<_StalledTlsPeer> nextPeer() async { + await _iterator.moveNext().timeout(const Duration(seconds: 2)); + return _iterator.current; + } + + Future close() async { + for (final peer in _peers) { + peer.socket.destroy(); + } + await _iterator.cancel(); + await _accepted.close(); + await _subscription.cancel(); + await server.close(); + } +} + +class _StalledTlsPeer { + final Socket socket; + final clientHello = Completer(); + final disconnected = Completer(); + + _StalledTlsPeer(this.socket) { + final bytes = []; + var stage = 0; + socket.listen((data) { + bytes.addAll(data); + if (stage == 0 && bytes.length >= 3) { + expect(bytes.sublist(0, 3), [5, 1, 0]); + bytes.removeRange(0, 3); + socket.add([5, 0]); + stage = 1; + } + if (stage == 1 && bytes.length >= 10) { + expect(bytes.sublist(0, 10), [5, 1, 0, 1, 8, 8, 8, 8, 1, 187]); + bytes.removeRange(0, 10); + socket.add([5, 0, 0, 1, 127, 0, 0, 1, 0, 0]); + stage = 2; + } + if (stage == 2 && bytes.length >= 6) { + // TLS handshake record followed by a ClientHello message. + expect(bytes[0], 22); + expect(bytes[5], 1); + clientHello.complete(); + stage = 3; + bytes.clear(); + } + }, onDone: disconnected.complete); + } +} + +// Test-only identity for dns.google; never added to the default trust store. +const _certificate = + 'test/services/openalias/fixtures/dns_google_test_cert.pem'; +const _privateKey = 'test/services/openalias/fixtures/dns_google_test_key.pem'; + +class _TlsSocksProxy { + final ServerSocket server; + final errors = []; + final _sockets = []; + final _tasks = >[]; + late final StreamSubscription _subscription; + + _TlsSocksProxy(this.server, Future Function(SecureSocket) handle) { + final context = SecurityContext() + ..useCertificateChain(_certificate) + ..usePrivateKey(_privateKey); + _subscription = server.listen((socket) { + _sockets.add(socket); + _tasks.add( + _serve(socket, context, handle).catchError((Object error) { + errors.add(error); + }), + ); + }); + } + + Future _serve( + Socket socket, + SecurityContext context, + Future Function(SecureSocket) handle, + ) async { + final reader = ChunkedStreamReader(socket); + expect(await reader.readBytes(3), [5, 1, 0]); + socket.add([5]); + await socket.flush(); + socket.add([0]); + expect(await reader.readBytes(10), [5, 1, 0, 1, 8, 8, 8, 8, 1, 187]); + // A fragmented domain-form reply also exercises exact SOCKS reads. + for (final byte in [5, 0, 0, 3, 3, 102, 111, 111, 0, 0]) { + socket.add([byte]); + await socket.flush(); + } + final secure = await SecureSocket.secureServer(socket, context); + _sockets.add(secure); + await handle(secure); + } + + static Future<_TlsSocksProxy> start( + Future Function(SecureSocket) handle, + ) async => _TlsSocksProxy( + await ServerSocket.bind(InternetAddress.loopbackIPv4, 0), + handle, + ); + + Future close() async { + for (final socket in _sockets) { + socket.destroy(); + } + await _subscription.cancel(); + await server.close(); + await Future.wait(_tasks).timeout(const Duration(seconds: 2)); + } +} diff --git a/test/services/openalias/fixtures/dns_google_test_cert.pem b/test/services/openalias/fixtures/dns_google_test_cert.pem new file mode 100644 index 0000000000..d000e0196b --- /dev/null +++ b/test/services/openalias/fixtures/dns_google_test_cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDJDCCAgygAwIBAgIUPbeZfpJ66kK5XfnheYjOKq4b0HkwDQYJKoZIhvcNAQEL +BQAwFTETMBEGA1UEAwwKZG5zLmdvb2dsZTAgFw0yNjEwMDExNTM5MDZaGA8yMTI2 +MDkwNzE1MzkwNlowFTETMBEGA1UEAwwKZG5zLmdvb2dsZTCCASIwDQYJKoZIhvcN +AQEBBQADggEPADCCAQoCggEBAMnviDZT0CPi3bWFd1WxNpZS/4tDxzLDyiddd0jg +C+n6C90Cjb2z5QckDRijd+4a9wC5EGVMQDyXoilxhKU/50HrpG3+PGmhLweYqbEI +MCXeVPHK8wWY6p1UmL4WzeEf90bFL7t17zA6uA/P5KoSSXbWkQ/nrJ/FBzlFYQqy +1Qq7RdV6t+XR43OblaJbHBG6+LKAGUUpwOVHBtNT5YN2VQiJXAy7hIaO3ZzU4WfI +5fBeUYDAsHtoRocDqKmwGNt0lzmrKeNKGO4drLMK2IYiLkYQVzCARr9e2yYNZzVI +ADDGXi2+mb1yhgwBPQsoi7tmw0tF3Ir82CnpCswh211l5qUCAwEAAaNqMGgwHQYD +VR0OBBYEFI3/+X5BRQKsDWOtawAgt5MeZkgfMB8GA1UdIwQYMBaAFI3/+X5BRQKs +DWOtawAgt5MeZkgfMA8GA1UdEwEB/wQFMAMBAf8wFQYDVR0RBA4wDIIKZG5zLmdv +b2dsZTANBgkqhkiG9w0BAQsFAAOCAQEAUJrI5uryhf0CbpTLNpg0kacDYv+l9jZ2 +DbYq6hKk980bUiIWT9oQR7U5Y1QAJXKrAzN4CsxGrOXcqYwyGRaJHgzP0gYe/uVK +Q34LWJtcXopOb72s9jLrOKPMJgTT558UnmiAKZbHpCyxIpfJVSv50/u0FYNM7txs +4DDaAxZSpVNlJ6WTWVmvTjWUp4yBLrveAHD6+P6Wyf/H/Nx2eEcDk8ylArOAQcDG +Pqd/bO6HgZm9vFsyScIxqpUPA6mV94qixSwnLnzHtnAXWEW/PgZ5+5I3J4fbtZBs +8CgoiE3Gyk4jMlfQ1W/DXbZRa+z5rj9KqiiDa6oV54svCHk+39HYsA== +-----END CERTIFICATE----- diff --git a/test/services/openalias/fixtures/dns_google_test_key.pem b/test/services/openalias/fixtures/dns_google_test_key.pem new file mode 100644 index 0000000000..5204031813 --- /dev/null +++ b/test/services/openalias/fixtures/dns_google_test_key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDJ74g2U9Aj4t21 +hXdVsTaWUv+LQ8cyw8onXXdI4Avp+gvdAo29s+UHJA0Yo3fuGvcAuRBlTEA8l6Ip +cYSlP+dB66Rt/jxpoS8HmKmxCDAl3lTxyvMFmOqdVJi+Fs3hH/dGxS+7de8wOrgP +z+SqEkl21pEP56yfxQc5RWEKstUKu0XVerfl0eNzm5WiWxwRuviygBlFKcDlRwbT +U+WDdlUIiVwMu4SGjt2c1OFnyOXwXlGAwLB7aEaHA6ipsBjbdJc5qynjShjuHayz +CtiGIi5GEFcwgEa/XtsmDWc1SAAwxl4tvpm9coYMAT0LKIu7ZsNLRdyK/Ngp6QrM +IdtdZealAgMBAAECggEABeDU5n30mGLVgN/4fBRbSi9y1Gc98HaeFxzDqOmqtx4q +A748CYpnz5gnUHELv/DlgU4h0c0RLqJcXFf7XEMatILLR89ni0yfnZk+UVKzchrB +fsNhZSeRJtDMXHY1+ohvbo41aD/T3WPMMVukJKZBILY4xU2Bayo1Xl+sZj8T8Aq7 +yYycZ/MRJ9CaZDsjgiSuRvzt5aa2RsaHp2CIvhUvswsvUXQkAVHRBegk9FQs7j++ +mCGsOWtT481a7/bOVxd29M9zQXspX3tXKUjrKPa48ByA+i13UvCkFBkwFkEWlcWG +6pQbjzWQqTatQCHvyBUB4uZehuqbxuGbIOdySYpWyQKBgQDvlR9QssF/KkY96EXK +lIc1w8H7O1mOt8ovzk1rFbUIQKc07FeqbRwdOuvnufAD56oADwwwDUL5kcfFCtOt +gMfH5SMGg3WdwHt+EbXbzqJJG/djR7OUS8/KHZ7cu9ZLmksLZ+yF/VYYC8rPig1b +a8pAy2APlLPgiOZ9xErT1LfISQKBgQDXxf1ozTfDJAcMiTNg0OCkR/wHFMTEHEUx +voRdj/ML3osdrl44tVVRaU0zLtHFBpE/partLbG41DDAuNPbhEzKdmTFN1DcYRmg +CXxhEqFJum0T46QSLLYlkLuKcthqXcWJ1+ZBEF+Ktqo89AxFi0Dc0lKjW8f8Oe9C +hUN2YE5DfQKBgQCs6t9YOjxoENXztqIwrXTNPEXUkyh3VnVqfabo5BjuJOPJ0c8Q +JYIGItHDRTNTTjAnrLSqNdgiErKxp7fR4UHQJ7RY7ibEvfkjq5qrU/9TbwjlsgmN +vg3yHeDcBgKMPIdvawmOR6OM5W3yzvwHAMxQabkiZbxSEdzx5uvu6fffaQKBgQCI +1O2WgE1UWox9zqPeSZerllqkwu7kzmiy2vZlvtuKM15VPDZUD+pC7lwekRpFr5x8 +mDqEne2OIEGqdYskMgngeqP8zM9icdEmQ920UXQmpSp26m0peWEpAVdsvNew3DZg +66EAsblk5eGfNcRydZR0dIwjPBjeQn8P2CKn9vqqoQKBgGdW1TpfMlsxf1e5LF9z +L/IYeBQrMabLF1NRED4sxQ/6JhtDOJrsTtLwYMiJlUvSN3PrJMaxPJQbmUQ14UEH +SdWT8jQTz3Ci5k5Duvoo5MX9HEJCDtNPVWPTL7V/Jyt/59XSXqO08kecPinH9bGk +P7aRRl5uF+WulHlJG2FfEoZS +-----END PRIVATE KEY----- From d0117ef25204edf7f4a58e4944aa6d7c23e002f3 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 1 Oct 2026 11:09:31 -0500 Subject: [PATCH 06/17] refactor(openalias): centralize send recipient classification --- lib/services/openalias/send_recipient.dart | 36 +++++++++++ .../openalias/send_recipient_test.dart | 60 +++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 lib/services/openalias/send_recipient.dart create mode 100644 test/services/openalias/send_recipient_test.dart diff --git a/lib/services/openalias/send_recipient.dart b/lib/services/openalias/send_recipient.dart new file mode 100644 index 0000000000..b6df0bf407 --- /dev/null +++ b/lib/services/openalias/send_recipient.dart @@ -0,0 +1,36 @@ +import 'open_alias.dart'; + +enum SendRecipientKind { literal, openAlias, invalid } + +// Local send-field classification only. Literal-address validators remain +// unchanged, and an alias is not a usable transaction recipient until resolved. +class SendRecipient { + final SendRecipientKind kind; + final String destination; + + const SendRecipient._(this.kind, this.destination); + + factory SendRecipient.classify( + String input, { + required bool supportsOpenAlias, + required bool Function(String) validateAddress, + }) { + final destination = input.trim(); + if (validateAddress(destination)) { + return SendRecipient._(SendRecipientKind.literal, destination); + } + if (supportsOpenAlias) { + try { + return SendRecipient._( + SendRecipientKind.openAlias, + normalizeOpenAlias(destination), + ); + } on OpenAliasException { + // Invalid input stays editable without initiating a lookup. + } + } + return SendRecipient._(SendRecipientKind.invalid, destination); + } + + bool get isAlias => kind == SendRecipientKind.openAlias; +} diff --git a/test/services/openalias/send_recipient_test.dart b/test/services/openalias/send_recipient_test.dart new file mode 100644 index 0000000000..ec9b0dd4df --- /dev/null +++ b/test/services/openalias/send_recipient_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/services/openalias/send_recipient.dart'; + +void main() { + SendRecipient classify(String input, {bool supportsOpenAlias = true}) => + SendRecipient.classify( + input, + supportsOpenAlias: supportsOpenAlias, + validateAddress: (value) => value == 'literal-address', + ); + + test('literal addresses take precedence and preserve case', () { + final result = classify(' literal-address '); + expect(result.kind, SendRecipientKind.literal); + expect(result.destination, 'literal-address'); + expect(result.isAlias, isFalse); + final domainAddress = SendRecipient.classify( + 'Native.Address', + supportsOpenAlias: true, + validateAddress: (_) => true, + ); + expect(domainAddress.kind, SendRecipientKind.literal); + expect(domainAddress.destination, 'Native.Address'); + }); + + test('domain and email aliases use OpenAlias normalization', () { + for (final input in [' Alice.Example. ', 'Alice@Example']) { + final result = classify(input); + expect(result.kind, SendRecipientKind.openAlias); + expect(result.destination, 'alice.example'); + } + }); + + test('invalid forms remain invalid without a lookup', () { + for (final input in [ + '', + 'alice', + 'a@@b.org', + 'https://alice.example', + 'a..org', + '127.0.0.1', + 'a.onion', + 'ü.example', + 'monero:literal-address', + ]) { + expect(classify(input).kind, SendRecipientKind.invalid, reason: input); + } + }); + + test('coins without the capability accept only their literal addresses', () { + expect( + classify('alice.example', supportsOpenAlias: false).kind, + SendRecipientKind.invalid, + ); + expect( + classify('literal-address', supportsOpenAlias: false).kind, + SendRecipientKind.literal, + ); + }); +} From 3f30ea315861e921131c79c58776dc7d18234c90 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 1 Oct 2026 11:26:50 -0500 Subject: [PATCH 07/17] feat(send): resolve Monero aliases on preview Accept plausible OpenAlias input directly in the Monero destination field without marking it as a valid literal address or looking it up while editing. Combine screen-local alias eligibility with the existing amount/data gates and retain each layout's applicable fee checks. Resolve through OpenAliasService after Preview, validate the result for the wallet, and prepare only the resolved literal address. Keep the source alias in the field and attach attribution to that attempt's TxData. Returning from confirmation resolves afresh; final send uses the prepared recipient. Share a per-screen attempt controller to suppress duplicate previews and ignore stale lookup/preparation results after edits, cancellation, disposal, or wallet changes. A modal progress route protects the draft during lookup; controller/provider edits invalidate it and source/wallet/fee checks guard preparation and navigation. Close only the progress route owned by the attempt, then reuse the existing transaction-failed presentation on errors. Remove the separate OpenAlias buttons and acceptance handlers. Add active tests of both real send screens and both confirmation layouts, with injected DNS records and fake wallets. Cover eligibility, no lookup on editing/fee estimation, duplicate clicks, fresh retries, error-route safety, late success/failure, cancellation during preparation, wallet changes, contact autofill, payment URIs, and final send without another lookup. No address-book, token, transport, or literal-address validator changes. --- lib/pages/send_view/send_view.dart | 289 +++++----- .../building_transaction_dialog.dart | 11 +- .../wallet_view/sub_widgets/desktop_send.dart | 329 ++++++------ .../ui/preview_tx_button_state_provider.dart | 89 ++-- lib/services/openalias/send_preview.dart | 135 +++++ lib/widgets/transaction_preview_dialog.dart | 127 +++++ .../send_view/open_alias_preview_test.dart | 501 ++++++++++++++++++ .../send_preview_eligibility_test.dart | 73 +++ .../services/openalias/send_preview_test.dart | 172 ++++++ 9 files changed, 1396 insertions(+), 330 deletions(-) create mode 100644 lib/services/openalias/send_preview.dart create mode 100644 lib/widgets/transaction_preview_dialog.dart create mode 100644 test/pages/send_view/open_alias_preview_test.dart create mode 100644 test/services/openalias/send_preview_eligibility_test.dart create mode 100644 test/services/openalias/send_preview_test.dart diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 31454e156c..66a5566bb7 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -29,8 +29,8 @@ import '../../providers/ui/fee_rate_type_state_provider.dart'; import '../../providers/ui/preview_tx_button_state_provider.dart'; import '../../providers/wallet/public_private_balance_state_provider.dart'; import '../../route_generator.dart'; -import '../../services/openalias/open_alias.dart'; -import '../../services/openalias/open_alias_service.dart'; +import '../../services/openalias/send_preview.dart'; +import '../../services/openalias/send_recipient.dart'; import '../../services/spark_names_service.dart'; import '../../themes/coin_icon_provider.dart'; import '../../themes/stack_colors.dart'; @@ -79,7 +79,7 @@ import '../../widgets/icon_widgets/clipboard_icon.dart'; import '../../widgets/icon_widgets/qrcode_icon.dart'; import '../../widgets/icon_widgets/x_icon.dart'; import '../../widgets/mwc_txs_method_toggle.dart'; -import '../../widgets/open_alias_dialog.dart'; +import '../../widgets/transaction_preview_dialog.dart'; import '../../widgets/rounded_white_container.dart'; import '../../widgets/stack_dialog.dart'; import '../../widgets/stack_text_field.dart'; @@ -127,40 +127,37 @@ class _SendViewState extends ConsumerState { late final CryptoCurrency coin; late final ClipboardInterface clipboard; - OpenAliasRecipient? _openAlias; - OpenAliasRecipient? get _acceptedOpenAlias => - _openAlias?.address == _address && - _openAlias?.address == sendToController.text - ? _openAlias - : null; + final _preview = SendPreview(); + List _previewInput = []; - Future _useOpenAlias() async { - final originalText = sendToController.text; - final result = await showDialog( - context: context, - builder: (_) => OpenAliasDialog( - initialInput: originalText.contains('.') || originalText.contains('@') - ? originalText - : '', - resolve: (input) => OpenAliasService().resolve( - input, - validateAddress: coin.validateAddress, - ), - ), - ); - if (!mounted || - result == null || - widget.walletId != walletId || - sendToController.text != originalText) { - return; + bool get _isOpenAliasInput => + coin is Monero && + SendRecipient.classify( + _address ?? '', + supportsOpenAlias: true, + validateAddress: coin.validateAddress, + ).isAlias; + + void _previewChanged() { + if (mounted) setState(() {}); + } + + void _previewInputChanged() { + final input = [ + sendToController, + cryptoAmountController, + baseAmountController, + memoController, + noteController, + onChainNoteController, + feeController, + ].map((c) => c.text).toList(); + if (input.length != _previewInput.length || + Iterable.generate(input.length) + .any((i) => input[i] != _previewInput[i])) { + _previewInput = input; + _preview.invalidate(); } - setState(() { - _address = result.address; - sendToController.text = result.address; - _openAlias = result; - _addressToggleFlag = true; - _setValidAddressProviders(result.address); - }); } late TextEditingController sendToController; @@ -185,7 +182,12 @@ class _SendViewState extends ConsumerState { late final bool isEth; Amount? _cachedAmountToSend; - String? _address; + String? _addressValue; + String? get _address => _addressValue; + set _address(String? value) { + if (_addressValue != value) _preview.invalidate(); + _addressValue = value; + } bool _addressToggleFlag = false; @@ -874,9 +876,41 @@ class _SendViewState extends ConsumerState { } Future _previewTransaction() async { + final source = _address ?? ''; + final draftFee = customFeeRate; + final draftEthFee = _ethFee.value; + final draftBalance = ref.read(publicPrivateBalanceStateProvider); + + final wallet = ref.read(pWallets).getWallet(walletId); + FocusScope.of(context).unfocus(); + await _preview.run( + walletId: walletId, + source: source, + isCurrent: () => + mounted && + widget.walletId == walletId && + (_address ?? '') == source && + customFeeRate == draftFee && + _ethFee.value == draftEthFee && + ref.read(publicPrivateBalanceStateProvider) == draftBalance && + identical(ref.read(pWallets).getWallet(walletId), wallet), + work: _preparePreview, + onError: (error, stack) { + Logging.instance.e( + 'Send preview failed', + error: error, + stackTrace: stack, + ); + showTransactionFailedDialog(context, error, isDesktop: false); + }, + ); + } + + Future _preparePreview(SendPreviewAttempt attempt) async { // wait for keyboard to disappear FocusScope.of(context).unfocus(); await Future.delayed(const Duration(milliseconds: 100)); + attempt.checkCurrent(); final wallet = ref.read(pWallets).getWallet(walletId); final Amount amount = ref.read(pSendAmount)!; @@ -949,6 +983,7 @@ class _SendViewState extends ConsumerState { ); } + attempt.checkCurrent(); if (shouldSendAll == null || shouldSendAll == false) { // cancel preview return; @@ -956,38 +991,42 @@ class _SendViewState extends ConsumerState { } } + attempt.checkCurrent(); + if (!mounted) return; + final progress = TransactionPreviewDialog(); + attempt.onClose(progress.close); try { - bool wasCancelled = false; - - if (mounted) { - unawaited( - showDialog( - context: context, - useSafeArea: false, - barrierDismissible: false, - builder: (context) { - return BuildingTransactionDialog( - coin: wallet.info.coin, - isSpark: - wallet is FiroWallet && - ref.read(publicPrivateBalanceStateProvider.state).state == - BalanceType.private, - onCancel: () { - wasCancelled = true; - - Navigator.of(context).pop(); - }, - ); - }, - ), - ); - } + progress.show( + context, + (context) => BuildingTransactionDialog( + coin: wallet.info.coin, + isSpark: + wallet is FiroWallet && + ref.read(publicPrivateBalanceStateProvider) == + BalanceType.private, + closeOnCancel: false, + onCancel: attempt.cancel, + ), + attempt.cancel, + ); final time = Future.delayed(const Duration(milliseconds: 2500)); Future txDataFuture; final feeRateType = ref.read(feeRateTypeMobileStateProvider); final satsPerVByte = feeRateType.customSatsPerVByte(customFeeRate); + final resolved = await attempt.resolve( + supportsOpenAlias: coin is Monero, + validateAddress: wallet.cryptoCurrency.validateAddress, + lookup: (input) => ref + .read(pSendOpenAliasService) + .resolve( + input, + validateAddress: wallet.cryptoCurrency.validateAddress, + ), + ); + attempt.checkCurrent(); + final destination = resolved.destination; if (isPaynymSend) { txDataFuture = (wallet as PaynymInterface).preparePaymentCodeSend( @@ -1021,7 +1060,7 @@ class _SendViewState extends ConsumerState { txData: TxData( sparkRecipients: [ ( - address: _address!, + address: destination, amount: amount, memo: memoController.text, isChange: false, @@ -1039,11 +1078,11 @@ class _SendViewState extends ConsumerState { txData: TxData( recipients: [ TxRecipient( - address: _address!, + address: destination, amount: amount, isChange: false, addressType: wallet.cryptoCurrency.getAddressType( - _address!, + destination, )!, ), ], @@ -1065,18 +1104,18 @@ class _SendViewState extends ConsumerState { ? null : [ TxRecipient( - address: _address!, + address: destination, amount: amount, isChange: false, addressType: wallet.cryptoCurrency.getAddressType( - _address!, + destination, )!, ), ], sparkRecipients: ref.read(pValidSparkSendToAddress) ? [ ( - address: _address!, + address: destination, amount: amount, memo: memoController.text, isChange: false, @@ -1094,10 +1133,10 @@ class _SendViewState extends ConsumerState { txData: TxData( recipients: [ TxRecipient( - address: _address!, + address: destination, amount: amount, isChange: false, - addressType: wallet.cryptoCurrency.getAddressType(_address!)!, + addressType: wallet.cryptoCurrency.getAddressType(destination)!, ), ], feeRateType: feeRateType, @@ -1116,13 +1155,13 @@ class _SendViewState extends ConsumerState { final memo = coin is Stellar ? memoController.text : null; txDataFuture = wallet.prepareSend( txData: TxData( - openAliasRecipient: _acceptedOpenAlias, + openAliasRecipient: resolved.alias, recipients: [ TxRecipient( - address: _address!, + address: destination, amount: amount, isChange: false, - addressType: wallet.cryptoCurrency.getAddressType(_address!)!, + addressType: wallet.cryptoCurrency.getAddressType(destination)!, ), ], memo: memo, @@ -1144,7 +1183,8 @@ class _SendViewState extends ConsumerState { TxData txData = results.first as TxData; - if (!wasCancelled && mounted) { + attempt.checkCurrent(); + if (mounted) { if (isPaynymSend) { txData = txData.copyWith( paynymAccountLite: widget.accountLite!, @@ -1157,8 +1197,7 @@ class _SendViewState extends ConsumerState { txData = txData.copyWith(noteOnChain: onChainNoteController.text); } - // pop building dialog - Navigator.of(context, rootNavigator: true).pop(); + progress.close(); unawaited( Navigator.of(context).push( @@ -1181,47 +1220,13 @@ class _SendViewState extends ConsumerState { ), ); } - } catch (e, s) { - Logging.instance.e("$e\n$s", error: e, stackTrace: s); - if (mounted) { - // pop building dialog - Navigator.of(context, rootNavigator: true).pop(); - - unawaited( - showDialog( - context: context, - useSafeArea: false, - barrierDismissible: true, - builder: (context) { - return StackDialog( - title: "Transaction failed", - message: e.toString(), - rightButton: TextButton( - style: Theme.of(context) - .extension()! - .getSecondaryEnabledButtonStyle(context), - child: Text( - "Ok", - style: STextStyles.button(context).copyWith( - color: Theme.of(context) - .extension()! - .accentColorDark, - ), - ), - onPressed: () { - Navigator.of(context).pop(); - }, - ), - ); - }, - ), - ); - } + } finally { + progress.close(); } } void clearSendForm() { - _openAlias = null; + _preview.invalidate(); if (!mounted) { return; } @@ -1346,11 +1351,6 @@ class _SendViewState extends ConsumerState { ); sendToController = TextEditingController(); - sendToController.addListener(() { - if (_openAlias != null && sendToController.text != _openAlias!.address) { - setState(() => _openAlias = null); - } - }); cryptoAmountController = TextEditingController(); baseAmountController = TextEditingController(); noteController = TextEditingController(); @@ -1358,6 +1358,20 @@ class _SendViewState extends ConsumerState { feeController = TextEditingController(); memoController = TextEditingController(); + _preview.addListener(_previewChanged); + for (final controller in [ + sendToController, + cryptoAmountController, + baseAmountController, + memoController, + noteController, + onChainNoteController, + feeController, + ]) { + controller.addListener(_previewInputChanged); + } + _previewInputChanged(); + onCryptoAmountChanged = _cryptoAmountChanged; cryptoAmountController.addListener(onCryptoAmountChanged); baseAmountController.addListener(_baseAmountChanged); @@ -1430,8 +1444,16 @@ class _SendViewState extends ConsumerState { super.initState(); } + @override + void didUpdateWidget(covariant SendView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.walletId != widget.walletId) _preview.invalidate(); + } + @override void dispose() { + _preview.removeListener(_previewChanged); + _preview.dispose(); _cryptoAmountChangedFeeUpdateTimer?.cancel(); _baseAmountChangedFeeUpdateTimer?.cancel(); _ethFee.dispose(); @@ -1459,6 +1481,16 @@ class _SendViewState extends ConsumerState { @override Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType"); + ref.listen(pSendAmount, (previous, next) { + if (previous != next) _preview.invalidate(); + }); + ref.listen(pOpReturnData, (previous, next) { + if (previous != next) _preview.invalidate(); + }); + ref.listen(feeRateTypeMobileStateProvider, (previous, next) { + if (previous != next) _preview.invalidate(); + }); + final isCustomFee = ref.watch(feeRateTypeMobileStateProvider).isCustom; final String locale = ref.watch( localeServiceChangeNotifierProvider.select((value) => value.locale), @@ -1474,7 +1506,14 @@ class _SendViewState extends ConsumerState { // ethFee is checked in the ValueListenableBuilder around the preview // button so fee keystrokes don't rebuild this whole view. final previewEnabled = - ref.watch(pPreviewTxButtonEnabled(coin)) && + !_preview.busy && + widget.walletId == walletId && + ref.watch( + pPreviewTxButtonEnabledForDestination(( + coin: coin, + isAlias: _isOpenAliasInput, + )), + ) && (ref.watch(pOpReturnData) == null || balType != BalanceType.private); final needsEthFee = isEth && isCustomFee; @@ -1738,15 +1777,12 @@ class _SendViewState extends ConsumerState { Text( isPaynymSend ? "Send to PayNym address" + : coin is Monero + ? "Send to address or OpenAlias" : "Send to", style: STextStyles.smallMed12(context), textAlign: TextAlign.left, ), - if (coin is Monero) - CustomTextButton( - text: "Use OpenAlias", - onTap: _useOpenAlias, - ), ], ), if (!isSlatepackMode) const SizedBox(height: 8), @@ -2058,7 +2094,8 @@ class _SendViewState extends ConsumerState { if (_data != null && _data.contactLabel == _address) { error = null; - } else if (!ref.watch(pValidSendToAddress)) { + } else if (!_isOpenAliasInput && + !ref.watch(pValidSendToAddress)) { error = "Invalid address"; } else { error = null; @@ -2090,10 +2127,6 @@ class _SendViewState extends ConsumerState { } }, ), - if (_acceptedOpenAlias != null) - OpenAliasAttribution( - recipient: _acceptedOpenAlias!, - ), if (isFiro || isMwebEnabled) const SizedBox(height: 12), if (isFiro || isMwebEnabled) diff --git a/lib/pages/send_view/sub_widgets/building_transaction_dialog.dart b/lib/pages/send_view/sub_widgets/building_transaction_dialog.dart index 0d1e9ef344..5fd25458f5 100644 --- a/lib/pages/send_view/sub_widgets/building_transaction_dialog.dart +++ b/lib/pages/send_view/sub_widgets/building_transaction_dialog.dart @@ -12,6 +12,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; + import '../../../themes/coin_image_provider.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/text_styles.dart'; @@ -27,11 +28,13 @@ class BuildingTransactionDialog extends ConsumerStatefulWidget { required this.onCancel, required this.coin, required this.isSpark, + this.closeOnCancel = true, }); final VoidCallback onCancel; final CryptoCurrency coin; final bool isSpark; + final bool closeOnCancel; @override ConsumerState createState() => @@ -115,7 +118,9 @@ class _RestoringDialogState extends ConsumerState { style: STextStyles.itemSubtitle12(context), ), onPressed: () { - Navigator.of(context).pop(); + if (widget.closeOnCancel) { + Navigator.of(context).pop(); + } onCancel.call(); }, ), @@ -140,7 +145,9 @@ class _RestoringDialogState extends ConsumerState { style: STextStyles.itemSubtitle12(context), ), onPressed: () { - Navigator.of(context).pop(); + if (widget.closeOnCancel) { + Navigator.of(context).pop(); + } onCancel.call(); }, ), 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 0dcdcd2899..21dfc306e9 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart @@ -34,8 +34,8 @@ import '../../../../providers/ui/fee_rate_type_state_provider.dart'; import '../../../../providers/ui/preview_tx_button_state_provider.dart'; import '../../../../providers/wallet/desktop_fee_providers.dart'; import '../../../../providers/wallet/public_private_balance_state_provider.dart'; -import '../../../../services/openalias/open_alias.dart'; -import '../../../../services/openalias/open_alias_service.dart'; +import '../../../../services/openalias/send_preview.dart'; +import '../../../../services/openalias/send_recipient.dart'; import '../../../../services/spark_names_service.dart'; import '../../../../themes/stack_colors.dart'; import '../../../../utilities/address_utils.dart'; @@ -80,7 +80,7 @@ import '../../../../widgets/icon_widgets/clipboard_icon.dart'; import '../../../../widgets/icon_widgets/qrcode_icon.dart'; import '../../../../widgets/icon_widgets/x_icon.dart'; import '../../../../widgets/mwc_txs_method_toggle.dart'; -import '../../../../widgets/open_alias_dialog.dart'; +import '../../../../widgets/transaction_preview_dialog.dart'; import '../../../../widgets/rounded_container.dart'; import '../../../../widgets/stack_text_field.dart'; import '../../../../widgets/textfield_icon_button.dart'; @@ -113,40 +113,35 @@ class _DesktopSendState extends ConsumerState { late final CryptoCurrency coin; late final ClipboardInterface clipboard; - OpenAliasRecipient? _openAlias; - OpenAliasRecipient? get _acceptedOpenAlias => - _openAlias?.address == _address && - _openAlias?.address == sendToController.text - ? _openAlias - : null; + final _preview = SendPreview(); + List _previewInput = []; - Future _useOpenAlias() async { - final originalText = sendToController.text; - final result = await showDialog( - context: context, - builder: (_) => OpenAliasDialog( - initialInput: originalText.contains('.') || originalText.contains('@') - ? originalText - : '', - resolve: (input) => OpenAliasService().resolve( - input, - validateAddress: coin.validateAddress, - ), - ), - ); - if (!mounted || - result == null || - widget.walletId != walletId || - sendToController.text != originalText) { - return; + bool get _isOpenAliasInput => + coin is Monero && + SendRecipient.classify( + _address ?? '', + supportsOpenAlias: true, + validateAddress: coin.validateAddress, + ).isAlias; + + void _previewChanged() { + if (mounted) setState(() {}); + } + + void _previewInputChanged() { + final input = [ + sendToController, + cryptoAmountController, + baseAmountController, + memoController, + nonceController, + ].map((c) => c.text).toList(); + if (input.length != _previewInput.length || + Iterable.generate(input.length) + .any((i) => input[i] != _previewInput[i])) { + _previewInput = input; + _preview.invalidate(); } - setState(() { - _address = result.address; - sendToController.text = result.address; - _openAlias = result; - _addressToggleFlag = true; - _setValidAddressProviders(result.address); - }); } late TextEditingController sendToController; @@ -171,7 +166,12 @@ class _DesktopSendState extends ConsumerState { String? _onChainNote; Amount? _cachedAmountToSend; - String? _address; + String? _addressValue; + String? get _address => _addressValue; + set _address(String? value) { + if (_addressValue != value) _preview.invalidate(); + _addressValue = value; + } bool _addressToggleFlag = false; @@ -468,6 +468,38 @@ class _DesktopSendState extends ConsumerState { } Future previewSend() async { + final source = _address ?? ''; + final draftFee = customFeeRate; + final draftEthFee = _ethFee.value; + final draftBalance = ref.read(publicPrivateBalanceStateProvider); + + final wallet = ref.read(pWallets).getWallet(walletId); + FocusScope.of(context).unfocus(); + await _preview.run( + walletId: walletId, + source: source, + isCurrent: () => + mounted && + widget.walletId == walletId && + (_address ?? '') == source && + customFeeRate == draftFee && + _ethFee.value == draftEthFee && + ref.read(publicPrivateBalanceStateProvider) == draftBalance && + identical(ref.read(pWallets).getWallet(walletId), wallet), + work: _preparePreview, + onError: (error, stack) { + Logging.instance.e( + 'Send preview failed', + error: error, + stackTrace: stack, + ); + showTransactionFailedDialog(context, error, isDesktop: true); + }, + ); + } + + Future _preparePreview(SendPreviewAttempt attempt) async { + attempt.checkCurrent(); final nonceInput = _nonceInput; if (!nonceInput.isValid) return; final nonce = nonceInput.value; @@ -580,6 +612,7 @@ class _DesktopSendState extends ConsumerState { }, ); + attempt.checkCurrent(); if (shouldSendAll == null || shouldSendAll == false) { // cancel preview return; @@ -587,41 +620,31 @@ class _DesktopSendState extends ConsumerState { } } + attempt.checkCurrent(); + if (!mounted) return; + final progress = TransactionPreviewDialog(); + attempt.onClose(progress.close); try { - bool wasCancelled = false; - - if (mounted) { - unawaited( - showDialog( - context: context, - useSafeArea: false, - barrierDismissible: false, - builder: (context) { - return DesktopDialog( - maxWidth: 400, - maxHeight: double.infinity, - child: Padding( - padding: const EdgeInsets.all(32), - child: BuildingTransactionDialog( - coin: wallet.info.coin, - isSpark: - wallet is FiroWallet && - ref - .read(publicPrivateBalanceStateProvider.state) - .state == - BalanceType.private, - onCancel: () { - wasCancelled = true; - - Navigator.of(context).pop(); - }, - ), - ), - ); - }, + progress.show( + context, + (context) => DesktopDialog( + maxWidth: 400, + maxHeight: double.infinity, + child: Padding( + padding: const EdgeInsets.all(32), + child: BuildingTransactionDialog( + coin: wallet.info.coin, + isSpark: + wallet is FiroWallet && + ref.read(publicPrivateBalanceStateProvider) == + BalanceType.private, + closeOnCancel: false, + onCancel: attempt.cancel, + ), ), - ); - } + ), + attempt.cancel, + ); final time = Future.delayed(const Duration(milliseconds: 2500)); @@ -629,6 +652,18 @@ class _DesktopSendState extends ConsumerState { Future txDataFuture; final feeRateType = ref.read(feeRateTypeDesktopStateProvider); final satsPerVByte = feeRateType.customSatsPerVByte(customFeeRate); + final resolved = await attempt.resolve( + supportsOpenAlias: coin is Monero, + validateAddress: wallet.cryptoCurrency.validateAddress, + lookup: (input) => ref + .read(pSendOpenAliasService) + .resolve( + input, + validateAddress: wallet.cryptoCurrency.validateAddress, + ), + ); + attempt.checkCurrent(); + final destination = resolved.destination; if (isPaynymSend) { final paynymWallet = wallet as PaynymInterface; @@ -662,7 +697,7 @@ class _DesktopSendState extends ConsumerState { txData: TxData( sparkRecipients: [ ( - address: _address!, + address: destination, amount: amount, memo: memoController.text, isChange: false, @@ -682,11 +717,11 @@ class _DesktopSendState extends ConsumerState { txData: TxData( recipients: [ TxRecipient( - address: _address!, + address: destination, amount: amount, isChange: false, addressType: wallet.cryptoCurrency.getAddressType( - _address!, + destination, )!, ), ], @@ -710,18 +745,18 @@ class _DesktopSendState extends ConsumerState { ? null : [ TxRecipient( - address: _address!, + address: destination, amount: amount, isChange: false, addressType: wallet.cryptoCurrency.getAddressType( - _address!, + destination, )!, ), ], sparkRecipients: ref.read(pValidSparkSendToAddress) ? [ ( - address: _address!, + address: destination, amount: amount, memo: memoController.text, isChange: false, @@ -739,10 +774,10 @@ class _DesktopSendState extends ConsumerState { txData: TxData( recipients: [ TxRecipient( - address: _address!, + address: destination, amount: amount, isChange: false, - addressType: wallet.cryptoCurrency.getAddressType(_address!)!, + addressType: wallet.cryptoCurrency.getAddressType(destination)!, ), ], feeRateType: feeRateType, @@ -760,13 +795,13 @@ class _DesktopSendState extends ConsumerState { final memo = hasOptionalMemo ? memoController.text : null; txDataFuture = wallet.prepareSend( txData: TxData( - openAliasRecipient: _acceptedOpenAlias, + openAliasRecipient: resolved.alias, recipients: [ TxRecipient( - address: _address!, + address: destination, amount: amount, isChange: false, - addressType: wallet.cryptoCurrency.getAddressType(_address!)!, + addressType: wallet.cryptoCurrency.getAddressType(destination)!, ), ], memo: memo, @@ -789,7 +824,8 @@ class _DesktopSendState extends ConsumerState { txData = results.first as TxData; - if (!wasCancelled && mounted) { + attempt.checkCurrent(); + if (mounted) { if (isPaynymSend) { txData = txData.copyWith( paynymAccountLite: widget.accountLite!, @@ -804,8 +840,7 @@ class _DesktopSendState extends ConsumerState { txData = txData.copyWith(noteOnChain: _onChainNote ?? ""); } } - // pop building dialog - Navigator.of(context, rootNavigator: true).pop(); + progress.close(); unawaited( showDialog( @@ -824,75 +859,13 @@ class _DesktopSendState extends ConsumerState { ), ); } - } catch (e, s) { - Logging.instance.e("Desktop send: ", error: e, stackTrace: s); - if (mounted) { - // pop building dialog - Navigator.of(context, rootNavigator: true).pop(); - - unawaited( - showDialog( - context: context, - builder: (context) { - return DesktopDialog( - maxWidth: 450, - maxHeight: double.infinity, - child: Padding( - padding: const EdgeInsets.only(left: 32, bottom: 32), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "Transaction failed", - style: STextStyles.desktopH3(context), - ), - const DesktopDialogCloseButton(), - ], - ), - const SizedBox(height: 12), - Padding( - padding: const EdgeInsets.only(right: 32), - child: Text( - e.toString(), - textAlign: TextAlign.left, - style: STextStyles.desktopTextExtraExtraSmall(context) - .copyWith(fontSize: 18), - ), - ), - const SizedBox(height: 40), - Row( - children: [ - Expanded( - child: SecondaryButton( - buttonHeight: ButtonHeight.l, - label: "Ok", - onPressed: () { - Navigator.of( - context, - rootNavigator: true, - ).pop(); - }, - ), - ), - const SizedBox(width: 32), - ], - ), - ], - ), - ), - ); - }, - ), - ); - } + } finally { + progress.close(); } } void clearSendForm() { - _openAlias = null; + _preview.invalidate(); if (!mounted) { return; } @@ -1303,16 +1276,23 @@ class _DesktopSendState extends ConsumerState { isEpiccash = coin is Epiccash; sendToController = TextEditingController(); - sendToController.addListener(() { - if (_openAlias != null && sendToController.text != _openAlias!.address) { - setState(() => _openAlias = null); - } - }); cryptoAmountController = TextEditingController(); baseAmountController = TextEditingController(); memoController = TextEditingController(); nonceController = TextEditingController(); + _preview.addListener(_previewChanged); + for (final controller in [ + sendToController, + cryptoAmountController, + baseAmountController, + memoController, + nonceController, + ]) { + controller.addListener(_previewInputChanged); + } + _previewInputChanged(); + onCryptoAmountChanged = _cryptoAmountChanged; cryptoAmountController.addListener(onCryptoAmountChanged); @@ -1372,8 +1352,16 @@ class _DesktopSendState extends ConsumerState { super.initState(); } + @override + void didUpdateWidget(covariant DesktopSend oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.walletId != widget.walletId) _preview.invalidate(); + } + @override void dispose() { + _preview.removeListener(_previewChanged); + _preview.dispose(); cryptoAmountController.removeListener(onCryptoAmountChanged); _ethFee.dispose(); @@ -1394,6 +1382,16 @@ class _DesktopSendState extends ConsumerState { @override Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType"); + ref.listen(pSendAmount, (previous, next) { + if (previous != next) _preview.invalidate(); + }); + ref.listen(pOpReturnData, (previous, next) { + if (previous != next) _preview.invalidate(); + }); + ref.listen(feeRateTypeDesktopStateProvider, (previous, next) { + if (previous != next) _preview.invalidate(); + }); + final String locale = ref.watch( localeServiceChangeNotifierProvider.select((value) => value.locale), ); @@ -1405,7 +1403,15 @@ class _DesktopSendState extends ConsumerState { final isCustomFee = ref.watch(feeRateTypeDesktopStateProvider).isCustom; // ethFee is checked in the ValueListenableBuilder around the preview // button so fee keystrokes don't rebuild this whole view. - final previewEnabled = ref.watch(pPreviewTxButtonEnabled(coin)); + final previewEnabled = + !_preview.busy && + widget.walletId == walletId && + ref.watch( + pPreviewTxButtonEnabledForDestination(( + coin: coin, + isAlias: _isOpenAliasInput, + )), + ); final needsEthFee = coin is Ethereum && isCustomFee; // add listener for epic cash to strip http:// and https:// prefixes if the address also ocntains an @ symbol (indicating an epicbox address) @@ -1831,16 +1837,14 @@ class _DesktopSendState extends ConsumerState { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - "Send to", + coin is Monero ? "Send to address or OpenAlias" : "Send to", style: STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveSearchIconRight, + color: Theme.of(context) + .extension()! + .textFieldActiveSearchIconRight, ), textAlign: TextAlign.left, ), - if (coin is Monero) - CustomTextButton(text: "Use OpenAlias", onTap: _useOpenAlias), ], ), if (!isPaynymSend && @@ -2058,7 +2062,8 @@ class _DesktopSendState extends ConsumerState { // For MWC/Epic slatepack transactions, address validation is not required. // TODO: When implementing encrypted slatepacks, address validation will be required. error = null; - } else if (!ref.watch(pValidSendToAddress)) { + } else if (!_isOpenAliasInput && + !ref.watch(pValidSendToAddress)) { error = "Invalid address"; } else { error = null; @@ -2086,8 +2091,6 @@ class _DesktopSendState extends ConsumerState { } }, ), - if (_acceptedOpenAlias != null) - OpenAliasAttribution(recipient: _acceptedOpenAlias!), // OP_RETURN metadata info (green, public mode only, with tooltip) Builder( builder: (context) { diff --git a/lib/providers/ui/preview_tx_button_state_provider.dart b/lib/providers/ui/preview_tx_button_state_provider.dart index 285d4b8781..5c2ea21a53 100644 --- a/lib/providers/ui/preview_tx_button_state_provider.dart +++ b/lib/providers/ui/preview_tx_button_state_provider.dart @@ -10,6 +10,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../services/openalias/open_alias_service.dart'; import '../../utilities/amount/amount.dart'; import '../../utilities/enums/epic_transaction_method.dart'; import '../../utilities/enums/mwc_transaction_method.dart'; @@ -49,50 +50,64 @@ final pIsSlatepack = Provider.family((ref, walletId) { return false; }); -final pPreviewTxButtonEnabled = Provider.autoDispose.family( - (ref, coin) { - final amount = ref.watch(pSendAmount) ?? Amount.zero; - final opReturnData = ref.watch(pOpReturnData); +final pPreviewTxButtonEnabled = Provider.autoDispose + .family((ref, coin) { + final amount = ref.watch(pSendAmount) ?? Amount.zero; + final opReturnData = ref.watch(pOpReturnData); - if (coin is! Firo && opReturnData != null) { - return false; - } + if (coin is! Firo && opReturnData != null) { + return false; + } - // For MWC slatepack transactions, address validation is not required. - if (coin is Mimblewimblecoin) { - final selectedMethod = ref.watch(pSelectedMwcTransactionMethod); - if (selectedMethod == MwcTransactionMethod.slatepack) { - return amount > Amount.zero; + // For MWC slatepack transactions, address validation is not required. + if (coin is Mimblewimblecoin) { + final selectedMethod = ref.watch(pSelectedMwcTransactionMethod); + if (selectedMethod == MwcTransactionMethod.slatepack) { + return amount > Amount.zero; + } } - } - // For Epic Cash slatepack transactions, address validation is not required. - if (coin is Epiccash) { - final selectedMethod = ref.watch(pSelectedEpicTransactionMethod); - if (selectedMethod == EpicTransactionMethod.slatepack) { - return amount > Amount.zero; + // For Epic Cash slatepack transactions, address validation is not required. + if (coin is Epiccash) { + final selectedMethod = ref.watch(pSelectedEpicTransactionMethod); + if (selectedMethod == EpicTransactionMethod.slatepack) { + return amount > Amount.zero; + } } - } - - if (coin is Firo) { - final firoType = ref.watch(publicPrivateBalanceStateProvider); - switch (firoType) { - case BalanceType.private: - return (ref.watch(pValidSendToAddress) || - ref.watch(pValidSparkSendToAddress)) && - !ref.watch(pIsExchangeAddress) && - opReturnData == null && - amount > Amount.zero; - - case BalanceType.public: - return ref.watch(pValidSendToAddress) && amount > Amount.zero; + + if (coin is Firo) { + final firoType = ref.watch(publicPrivateBalanceStateProvider); + switch (firoType) { + case BalanceType.private: + return (ref.watch(pValidSendToAddress) || + ref.watch(pValidSparkSendToAddress)) && + !ref.watch(pIsExchangeAddress) && + opReturnData == null && + amount > Amount.zero; + + case BalanceType.public: + return ref.watch(pValidSendToAddress) && amount > Amount.zero; + } + } else { + return ref.watch(pValidSendToAddress) && amount > Amount.zero; } - } else { - return ref.watch(pValidSendToAddress) && amount > Amount.zero; - } - }, -); + }); final previewTokenTxButtonStateProvider = StateProvider.autoDispose((_) { return false; }); + +// Alias eligibility is supplied by the individual send screen rather than +// stored in the shared literal-address validation provider. +final pPreviewTxButtonEnabledForDestination = Provider.autoDispose + .family((ref, input) { + if (input.coin is Monero && input.isAlias) { + return (ref.watch(pSendAmount) ?? Amount.zero) > Amount.zero && + ref.watch(pOpReturnData) == null; + } + return ref.watch(pPreviewTxButtonEnabled(input.coin)); + }); + +final pSendOpenAliasService = Provider( + (_) => OpenAliasService(), +); diff --git a/lib/services/openalias/send_preview.dart b/lib/services/openalias/send_preview.dart new file mode 100644 index 0000000000..07df38ce49 --- /dev/null +++ b/lib/services/openalias/send_preview.dart @@ -0,0 +1,135 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import 'open_alias.dart'; +import 'send_recipient.dart'; + +class ResolvedSendRecipient { + final String destination; + final OpenAliasRecipient? alias; + + const ResolvedSendRecipient(this.destination, [this.alias]); +} + +// One instance per send screen, never shared between wallets. Invalidating an +// attempt prevents late lookup/preparation results from reaching confirmation. +class SendPreview extends ChangeNotifier { + SendPreviewAttempt? _active; + bool _disposed = false; + + bool get busy => _active != null; + + Future run({ + required String walletId, + required String source, + required bool Function() isCurrent, + required Future Function(SendPreviewAttempt) work, + required void Function(Object, StackTrace) onError, + }) async { + if (_disposed || busy || !isCurrent()) return; + final attempt = SendPreviewAttempt._(this, walletId, source, isCurrent); + _active = attempt; + notifyListeners(); + try { + await work(attempt); + } on _StalePreview { + // A new edit, cancellation, wallet, or screen owns the user's intent. + } catch (error, stack) { + if (attempt.isCurrent) onError(error, stack); + } finally { + attempt._close(); + if (identical(_active, attempt)) { + _active = null; + if (!_disposed) notifyListeners(); + } + } + } + + void invalidate() { + final attempt = _active; + _active = null; + attempt?._close(); + if (attempt != null && !_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + invalidate(); + super.dispose(); + } +} + +class SendPreviewAttempt { + final SendPreview _owner; + final String walletId; + final String source; + final bool Function() _isCurrent; + final _cleanup = []; + + SendPreviewAttempt._( + this._owner, + this.walletId, + this.source, + this._isCurrent, + ); + + bool get isCurrent => + !_owner._disposed && identical(_owner._active, this) && _isCurrent(); + + void checkCurrent() { + if (!isCurrent) throw const _StalePreview(); + } + + void cancel() { + if (identical(_owner._active, this)) _owner.invalidate(); + } + + void onClose(VoidCallback cleanup) { + checkCurrent(); + _cleanup.add(cleanup); + } + + void _close() { + for (final cleanup in _cleanup) { + cleanup(); + } + _cleanup.clear(); + } + + Future resolve({ + required bool supportsOpenAlias, + required bool Function(String) validateAddress, + required Future Function(String) lookup, + }) async { + checkCurrent(); + // Non-Monero destinations retain their existing coin-specific handling + // (including Spark, PayNym and slatepack). + if (!supportsOpenAlias) return ResolvedSendRecipient(source); + final input = SendRecipient.classify( + source, + supportsOpenAlias: true, + validateAddress: validateAddress, + ); + if (input.kind == SendRecipientKind.literal) { + return ResolvedSendRecipient(input.destination); + } + if (!input.isAlias) { + throw const OpenAliasException('Enter a valid address or OpenAlias.'); + } + final recipient = await lookup(input.destination); + checkCurrent(); + if (!validateAddress(recipient.address) || + recipient.domain != input.destination) { + throw const OpenAliasException( + 'OpenAlias did not return a valid recipient for this wallet.', + ); + } + return ResolvedSendRecipient(recipient.address, recipient); + } +} + +class _StalePreview implements Exception { + const _StalePreview(); +} diff --git a/lib/widgets/transaction_preview_dialog.dart b/lib/widgets/transaction_preview_dialog.dart new file mode 100644 index 0000000000..a8fb2a1ab8 --- /dev/null +++ b/lib/widgets/transaction_preview_dialog.dart @@ -0,0 +1,127 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../themes/stack_colors.dart'; +import '../utilities/text_styles.dart'; +import 'desktop/desktop_dialog.dart'; +import 'desktop/desktop_dialog_close_button.dart'; +import 'desktop/secondary_button.dart'; +import 'stack_dialog.dart'; + +// Removes only the route created by this attempt, even after cancellation or +// when another route becomes current. Never blindly pops the send view. +class TransactionPreviewDialog { + DialogRoute? _route; + + void show( + BuildContext context, + WidgetBuilder builder, + VoidCallback onCancel, + ) { + final navigator = Navigator.of(context, rootNavigator: true); + final route = DialogRoute( + context: context, + barrierDismissible: false, + useSafeArea: false, + builder: (context) => PopScope(canPop: false, child: builder(context)), + ); + _route = route; + unawaited( + navigator.push(route).then((_) { + if (identical(_route, route)) { + _route = null; + onCancel(); + } + }), + ); + } + + void close() { + final route = _route; + _route = null; + if (route != null && route.isActive) { + route.navigator?.removeRoute(route); + } + } +} + +void showTransactionFailedDialog( + BuildContext context, + Object error, { + required bool isDesktop, +}) { + unawaited( + showDialog( + context: context, + useSafeArea: false, + builder: (context) { + if (!isDesktop) { + return StackDialog( + title: 'Transaction failed', + message: error.toString(), + rightButton: TextButton( + style: Theme.of(context) + .extension()! + .getSecondaryEnabledButtonStyle(context), + onPressed: () => Navigator.of(context).pop(), + child: Text( + 'Ok', + style: STextStyles.button(context).copyWith( + color: Theme.of(context) + .extension()! + .accentColorDark, + ), + ), + ), + ); + } + return DesktopDialog( + maxWidth: 450, + maxHeight: double.infinity, + child: Padding( + padding: const EdgeInsets.only(left: 32, bottom: 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Transaction failed', + style: STextStyles.desktopH3(context), + ), + const DesktopDialogCloseButton(), + ], + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.only(right: 32), + child: Text( + error.toString(), + textAlign: TextAlign.left, + style: STextStyles.desktopTextExtraExtraSmall(context) + .copyWith(fontSize: 18), + ), + ), + const SizedBox(height: 40), + Row( + children: [ + Expanded( + child: SecondaryButton( + buttonHeight: ButtonHeight.l, + label: 'Ok', + onPressed: () => Navigator.of(context).pop(), + ), + ), + const SizedBox(width: 32), + ], + ), + ], + ), + ), + ); + }, + ), + ); +} diff --git a/test/pages/send_view/open_alias_preview_test.dart b/test/pages/send_view/open_alias_preview_test.dart new file mode 100644 index 0000000000..f3fb1db9df --- /dev/null +++ b/test/pages/send_view/open_alias_preview_test.dart @@ -0,0 +1,501 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:stackwallet/models/send_view_auto_fill_data.dart'; +import 'package:stackwallet/services/openalias/open_alias.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/balance.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/address.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/models/paymint/fee_object_model.dart'; +import 'package:stackwallet/pages/send_view/send_view.dart'; +import 'package:stackwallet/pages/send_view/confirm_transaction_view.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart'; +import 'package:stackwallet/providers/providers.dart'; +import 'package:stackwallet/providers/ui/preview_tx_button_state_provider.dart'; +import 'package:stackwallet/services/openalias/open_alias_service.dart'; +import 'package:stackwallet/services/wallets.dart'; +import 'package:stackwallet/themes/coin_icon_provider.dart'; +import 'package:stackwallet/themes/coin_image_provider.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/themes/theme_providers.dart'; +import 'package:stackwallet/utilities/amount/amount.dart'; +import 'package:stackwallet/utilities/amount/amount_unit.dart'; +import 'package:stackwallet/utilities/prefs.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/isar/models/wallet_info.dart'; +import 'package:stackwallet/wallets/isar/providers/wallet_info_provider.dart'; +import 'package:stackwallet/wallets/models/tx_data.dart'; +import 'package:stackwallet/wallets/wallet/intermediate/cryptonote_wallet.dart'; +import 'package:stackwallet/wallets/wallet/wallet.dart'; + +import '../../sample_data/theme_json.dart'; + +const literal = 'literal-monero-recipient'; +Amount amount(int value) => + Amount(rawValue: BigInt.from(value), fractionDigits: 12); + +class _Monero extends Monero { + _Monero() : super(CryptoCurrencyNetwork.main); + @override + bool validateAddress(String address) => + address == literal || address == 'second-recipient'; + @override + AddressType? getAddressType(String address) { + if (!validateAddress(address)) { + throw StateError('Unresolved recipient: $address'); + } + return AddressType.cryptonote; + } +} + +class _Info extends Fake implements WalletInfo { + _Info(this.coin); + @override + final CryptoCurrency coin; + @override + String get walletId => 'wallet'; + @override + String get name => 'Test wallet'; + @override + bool get isMwebEnabled => false; + @override + Balance get cachedBalance => Balance( + total: amount(10000000000000), + spendable: amount(10000000000000), + blockedTotal: amount(0), + pendingSpendable: amount(0), + ); +} + +class _Wallet extends Fake implements CryptonoteWallet { + _Wallet(this.cryptoCurrency) : info = _Info(cryptoCurrency); + @override + final Monero cryptoCurrency; + @override + final WalletInfo info; + final prepared = []; + Completer? preparation; + final sent = []; + @override + Future confirmSend({required TxData txData}) async { + sent.add(txData); + throw StateError('simulated broadcast failure'); + } + + @override + Future get fees async => FeeObject( + numberOfBlocksFast: 1, + numberOfBlocksAverage: 2, + numberOfBlocksSlow: 3, + fast: BigInt.one, + medium: BigInt.one, + slow: BigInt.one, + ); + @override + int getTxPriorityHigh() => 3; + @override + int getTxPriorityMedium() => 2; + @override + int getTxPriorityNormal() => 1; + @override + Future estimateFeeFor(Amount value, BigInt feeRate) async => + amount(10); + @override + Future prepareSend({required TxData txData}) async { + prepared.add(txData); + return preparation?.future ?? txData.copyWith(fee: amount(10)); + } +} + +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; + @override + int maxDecimals(CryptoCurrency coin) => coin.fractionDigits; + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _AuthObserver extends NavigatorObserver { + bool approveNext = false; + @override + void didPush(Route route, Route? previousRoute) { + if (approveNext) { + approveNext = false; + // Supply the authentication result without involving PIN/password storage. + scheduleMicrotask(() => navigator!.pop(true)); + } + } +} + +class _Harness { + final bool desktop; + final _Monero coin = _Monero(); + late final _Wallet wallet = _Wallet(coin); + late final ProviderContainer container; + final navigator = GlobalKey(); + final identity = ValueNotifier('wallet'); + final visible = ValueNotifier(true); + final auth = _AuthObserver(); + final lookups = >>[]; + int get calls => lookups.length; + _Harness(this.desktop); + + Future mount( + WidgetTester tester, { + SendViewAutoFillData? autofill, + }) async { + tester.view.physicalSize = Size(desktop ? 1200 : 600, 1600); + tester.view.devicePixelRatio = 1; + Util.screenWidth = desktop ? 1200 : 600; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(() => Util.screenWidth = null); + final theme = StackTheme.fromJson(json: lightThemeJsonMap); + container = ProviderContainer( + overrides: [ + themeProvider.overrideWithProvider(StateProvider((_) => theme)), + pWallets.overrideWithValue(_Wallets(wallet)), + for (final id in ['wallet', 'other-wallet']) ...[ + pWalletInfo(id).overrideWithValue(wallet.info), + pWalletCoin(id).overrideWithValue(coin), + pWalletName(id).overrideWithValue('Test wallet'), + pWalletBalance(id).overrideWithValue(wallet.info.cachedBalance), + ], + prefsChangeNotifierProvider.overrideWithValue(_Prefs()), + coinIconProvider(coin) + .overrideWithValue('test/sample_data/light/assets/dummy.svg'), + coinImageSecondaryProvider(coin).overrideWithValue('dummy.svg'), + pSendOpenAliasService.overrideWithValue( + OpenAliasService( + externalCalls: () => true, + useTor: () => false, + lookup: (domain, _) { + if (domain != 'alice.example') { + throw StateError('Unexpected lookup: $domain'); + } + final pending = Completer>(); + lookups.add(pending); + return pending.future; + }, + ), + ), + ], + ); + addTearDown(() async { + await tester.pumpWidget(const SizedBox()); + await tester.pumpAndSettle(); + container.dispose(); + identity.dispose(); + visible.dispose(); + }); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + navigatorKey: navigator, + navigatorObservers: [auth], + theme: ThemeData( + extensions: [StackColors.fromStackColorTheme(theme)], + ), + home: const Scaffold(body: Text('Home')), + ), + ), + ); + unawaited( + navigator.currentState!.push( + MaterialPageRoute( + builder: (_) => ValueListenableBuilder( + valueListenable: visible, + builder: (_, show, _) => !show + ? const SizedBox() + : ValueListenableBuilder( + valueListenable: identity, + builder: (_, id, _) => desktop + ? Scaffold( + body: SingleChildScrollView( + child: DesktopSend( + walletId: id, + autoFillData: autofill, + ), + ), + ) + : SendView( + walletId: id, + coin: coin, + autoFillData: autofill, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + } + + Finder get field => find.byKey(const Key('sendViewAddressFieldKey')); + Finder get button => find.text(desktop ? 'Preview send' : 'Preview'); + String source(WidgetTester tester) => + tester.widget(field).controller!.text; + bool enabled(WidgetTester tester) => desktop + ? tester + .widget( + find.ancestor(of: button, matching: find.byType(PrimaryButton)), + ) + .enabled + : tester + .widget( + find.ancestor(of: button, matching: find.byType(TextButton)), + ) + .onPressed != + null; + + Future enter(WidgetTester tester, String destination) async { + await tester.enterText(field, destination); + await tester.pumpAndSettle(); + } + + Future setAmount(WidgetTester tester) async { + container.read(pSendAmount.notifier).state = amount(1000000000000); + await tester.pumpAndSettle(); + } + + Future preview(WidgetTester tester, {bool twice = false}) async { + await tester.ensureVisible(button); + if (twice) { + final callback = desktop + ? tester + .widget( + find.ancestor( + of: button, + matching: find.byType(PrimaryButton), + ), + ) + .onPressed! + : tester + .widget( + find.ancestor(of: button, matching: find.byType(TextButton)), + ) + .onPressed!; + callback(); + callback(); + } else { + await tester.tap(button); + } + await tester.pump(const Duration(milliseconds: 150)); + await tester.pump(); + } + + Future finish(WidgetTester tester) async { + await tester.pump(); + await tester.pump(const Duration(seconds: 3)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + } + + void succeed([String destination = literal]) => + lookups.last.complete(['oa1:xmr recipient_address=$destination;']); +} + +void main() { + for (final desktop in [false, true]) { + final layout = desktop ? 'desktop' : 'mobile'; + testWidgets( + '$layout resolves on Preview and shows each prepared recipient', + (tester) async { + final h = _Harness(desktop); + await h.mount(tester); + await h.enter(tester, 'Alice@Example'); + expect(h.calls, 0); + expect(h.enabled(tester), isFalse); + expect(h.container.read(pValidSendToAddress), isFalse); + expect(find.text('Invalid address'), findsNothing); + expect(find.text('Use OpenAlias'), findsNothing); + await h.setAmount(tester); + expect(h.enabled(tester), isTrue); + expect(h.calls, 0); // Amount changes and fee estimation are local. + await h.preview(tester, twice: true); + expect(h.calls, 1); + expect(h.wallet.prepared, isEmpty); + h.succeed(); + await h.finish(tester); + expect(h.wallet.prepared.single.recipients!.single.address, literal); + expect( + h.wallet.prepared.single.openAliasRecipient!.domain, + 'alice.example', + ); + expect(find.byType(ConfirmTransactionView), findsOneWidget); + expect(find.text('alice.example'), findsOneWidget); + expect(find.text(literal), findsOneWidget); + h.navigator.currentState!.pop(); + await tester.pumpAndSettle(); + expect(h.source(tester), 'Alice@Example'); + await h.preview(tester); + expect(h.calls, 2); + h.succeed('second-recipient'); + await h.finish(tester); + expect( + h.wallet.prepared.last.recipients!.single.address, + 'second-recipient', + ); + expect(find.text('second-recipient'), findsOneWidget); + expect(find.text('alice.example'), findsOneWidget); + // Final send receives the prepared transaction, without another lookup. + h.auth.approveNext = true; + await tester.ensureVisible(find.text('Send')); + await tester.tap(find.text('Send')); + await h.finish(tester); + expect( + h.wallet.sent.single.recipients!.single.address, + 'second-recipient', + ); + expect( + h.wallet.sent.single.openAliasRecipient!.domain, + 'alice.example', + ); + expect(h.calls, 2); + }, + ); + + testWidgets('$layout lookup failure retains input and the send route', ( + tester, + ) async { + final h = _Harness(desktop); + await h.mount(tester); + await h.enter(tester, 'Alice@Example'); + await h.setAmount(tester); + await h.preview(tester); + h.lookups.single.completeError( + const OpenAliasException('DNSSEC verification failed.'), + ); + await h.finish(tester); + expect(h.wallet.prepared, isEmpty); + expect(find.byType(ConfirmTransactionView), findsNothing); + expect(find.text('Transaction failed'), findsOneWidget); + expect(find.text('DNSSEC verification failed.'), findsOneWidget); + await tester.tap(find.text('Ok')); + await tester.pumpAndSettle(); + expect(h.source(tester), 'Alice@Example'); + expect(h.navigator.currentState!.canPop(), isTrue); + expect(h.enabled(tester), isTrue); + }); + + for (final change in [ + 'edit away and back', + 'amount', + 'cancel', + 'wallet', + 'dispose', + ]) { + testWidgets('$layout $change discards a pending lookup', (tester) async { + final h = _Harness(desktop); + await h.mount(tester); + await h.enter(tester, 'Alice@Example'); + await h.setAmount(tester); + await h.preview(tester); + expect(h.calls, 1); + switch (change) { + case 'edit away and back': + final controller = tester.widget(h.field).controller!; + controller.text = 'bob.example'; + controller.text = 'Alice@Example'; + case 'amount': + h.container.read(pSendAmount.notifier).state = amount(2); + case 'cancel': + await tester.tap(find.text('Cancel')); + case 'wallet': + h.identity.value = 'other-wallet'; + await tester.pump(); + h.identity.value = 'wallet'; + await tester.pump(); + case 'dispose': + h.visible.value = false; + await tester.pump(); + } + h.succeed(); + await h.finish(tester); + expect(h.wallet.prepared, isEmpty); + expect(find.byType(ConfirmTransactionView), findsNothing); + expect(find.text('Transaction failed'), findsNothing); + expect(h.navigator.currentState!.canPop(), isTrue); + }); + } + + for (final fail in [false, true]) { + testWidgets('$layout cancellation during preparation ignores late ' + '${fail ? 'error' : 'success'}', (tester) async { + final h = _Harness(desktop); + await h.mount(tester); + h.wallet.preparation = Completer(); + await h.enter(tester, 'Alice@Example'); + await h.setAmount(tester); + await h.preview(tester); + h.succeed(); + await tester.pump(); + expect(h.wallet.prepared, hasLength(1)); + await tester.tap(find.text('Cancel')); + if (fail) { + h.wallet.preparation!.completeError( + StateError('late preparation failure'), + ); + } else { + h.wallet.preparation!.complete( + h.wallet.prepared.single.copyWith(fee: amount(10)), + ); + } + await h.finish(tester); + expect(find.byType(ConfirmTransactionView), findsNothing); + expect(find.text('Transaction failed'), findsNothing); + expect(h.source(tester), 'Alice@Example'); + expect(h.navigator.currentState!.canPop(), isTrue); + }); + } + for (final input in ['literal', 'contact', 'uri']) { + testWidgets('$layout $input prepares a literal address without lookup', ( + tester, + ) async { + final h = _Harness(desktop); + await h.mount( + tester, + autofill: input == 'contact' + ? SendViewAutoFillData( + address: literal, + contactLabel: 'Alice contact', + ) + : null, + ); + if (input != 'contact') { + await h.enter( + tester, + input == 'uri' ? 'monero:$literal?amount=1' : literal, + ); + } + await h.setAmount(tester); + expect(h.enabled(tester), isTrue); + await h.preview(tester); + await h.finish(tester); + expect(h.calls, 0); + expect(h.wallet.prepared.single.recipients!.single.address, literal); + expect(h.wallet.prepared.single.openAliasRecipient, isNull); + }); + } + } +} diff --git a/test/services/openalias/send_preview_eligibility_test.dart b/test/services/openalias/send_preview_eligibility_test.dart new file mode 100644 index 0000000000..5fce56bd78 --- /dev/null +++ b/test/services/openalias/send_preview_eligibility_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/providers/ui/preview_tx_button_state_provider.dart'; +import 'package:stackwallet/utilities/amount/amount.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; + +void main() { + test( + 'alias eligibility requires an amount and respects unsupported data', + () { + final container = ProviderContainer(); + addTearDown(container.dispose); + final coin = Monero(CryptoCurrencyNetwork.main); + final enabled = pPreviewTxButtonEnabledForDestination(( + coin: coin, + isAlias: true, + )); + final subscription = container.listen(enabled, (_, _) {}); + addTearDown(subscription.close); + expect(container.read(enabled), isFalse); + container.read(pSendAmount.notifier).state = Amount( + rawValue: BigInt.one, + fractionDigits: 12, + ); + expect(container.read(enabled), isTrue); + expect(container.read(pValidSendToAddress), isFalse); + container.read(pOpReturnData.notifier).state = '00'; + expect(container.read(enabled), isFalse); + container.read(pOpReturnData.notifier).state = null; + container.read(pSendAmount.notifier).state = Amount.zero; + expect(container.read(enabled), isFalse); + }, + ); + + test('alias state is local and never enables another wallet or coin', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + final monero = Monero(CryptoCurrencyNetwork.main); + final bitcoin = Bitcoin(CryptoCurrencyNetwork.main); + final alias = pPreviewTxButtonEnabledForDestination(( + coin: monero, + isAlias: true, + )); + final literal = pPreviewTxButtonEnabledForDestination(( + coin: monero, + isAlias: false, + )); + final other = pPreviewTxButtonEnabledForDestination(( + coin: bitcoin, + isAlias: true, + )); + final subscriptions = [ + alias, + literal, + other, + ].map((p) => container.listen(p, (_, _) {})).toList(); + addTearDown(() { + for (final s in subscriptions) { + s.close(); + } + }); + container.read(pSendAmount.notifier).state = Amount( + rawValue: BigInt.one, + fractionDigits: 12, + ); + expect(container.read(alias), isTrue); + expect(container.read(literal), isFalse); + expect(container.read(other), isFalse); + container.read(pValidSendToAddress.notifier).state = true; + expect(container.read(literal), isTrue); + expect(container.read(other), isTrue); + }); +} diff --git a/test/services/openalias/send_preview_test.dart b/test/services/openalias/send_preview_test.dart new file mode 100644 index 0000000000..793cd60ae4 --- /dev/null +++ b/test/services/openalias/send_preview_test.dart @@ -0,0 +1,172 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/services/openalias/open_alias.dart'; +import 'package:stackwallet/services/openalias/send_preview.dart'; + +void main() { + late SendPreview preview; + late List prepared; + late List errors; + var calls = 0; + setUp(() { + preview = SendPreview(); + prepared = []; + errors = []; + calls = 0; + }); + tearDown(() => preview.dispose()); + + Future run({ + String source = 'Alice@Example', + Future? pending, + bool Function()? current, + Future? preparing, + }) => preview.run( + walletId: 'wallet', + source: source, + isCurrent: current ?? () => true, + work: (attempt) async { + final result = await attempt.resolve( + supportsOpenAlias: true, + validateAddress: (address) => address == 'literal', + lookup: (domain) { + calls++; + expect(domain, 'alice.example'); + return pending ?? + Future.value( + const OpenAliasRecipient( + domain: 'alice.example', + address: 'literal', + ), + ); + }, + ); + attempt.checkCurrent(); + prepared.add(result); + if (preparing != null) await preparing; + attempt.checkCurrent(); + }, + onError: (e, _) => errors.add(e), + ); + + test('literal address bypasses lookup', () async { + await run(source: 'literal'); + expect(calls, 0); + expect(prepared.single.destination, 'literal'); + expect(prepared.single.alias, isNull); + }); + test( + 'each preview resolves anew and prepares only a validated address', + () async { + await run(); + await run(); + expect(calls, 2); + expect(prepared.map((e) => e.destination), ['literal', 'literal']); + expect(prepared.last.alias!.domain, 'alice.example'); + expect(errors, isEmpty); + }, + ); + test('lookup failures do not prepare', () async { + await run(pending: Future.error(const OpenAliasException('DNSSEC failed'))); + expect(prepared, isEmpty); + expect(errors.single.toString(), 'DNSSEC failed'); + expect(preview.busy, isFalse); + }); + test('invalid resolver results do not prepare', () async { + await run( + pending: Future.value( + const OpenAliasRecipient( + domain: 'alice.example', + address: 'wrong-wallet', + ), + ), + ); + expect(prepared, isEmpty); + expect(errors.single, isA()); + }); + test('duplicate previews are suppressed while lookup is pending', () async { + final pending = Completer(); + final first = run(pending: pending.future); + await run(); + expect(calls, 1); + pending.complete( + const OpenAliasRecipient(domain: 'alice.example', address: 'literal'), + ); + await first; + expect(prepared, hasLength(1)); + }); + for (final reason in ['edit away and back', 'cancel', 'wallet switch']) { + test('$reason invalidates old success and permits a new attempt', () async { + final pending = Completer(); + final first = run(pending: pending.future); + preview.invalidate(); + await run(); + pending.complete( + const OpenAliasRecipient(domain: 'alice.example', address: 'literal'), + ); + await first; + expect(prepared, hasLength(1)); + expect(errors, isEmpty); + }); + } + test('a stale failure cannot show an error over the new draft', () async { + final pending = Completer(); + final first = run(pending: pending.future); + preview.invalidate(); + pending.completeError(const OpenAliasException('old failure')); + await first; + expect(prepared, isEmpty); + expect(errors, isEmpty); + }); + test( + 'external wallet/source identity is checked before preparation', + () async { + var current = true; + final pending = Completer(); + final first = run(pending: pending.future, current: () => current); + current = false; + pending.complete( + const OpenAliasRecipient(domain: 'alice.example', address: 'literal'), + ); + await first; + expect(prepared, isEmpty); + }, + ); + test('cancel during preparation prevents navigation', () async { + final pending = Completer(); + var navigated = false; + final first = preview.run( + walletId: 'wallet', + source: 'literal', + isCurrent: () => true, + work: (attempt) async { + await pending.future; + attempt.checkCurrent(); + navigated = true; + }, + onError: (e, _) => errors.add(e), + ); + preview.invalidate(); + pending.complete(); + await first; + expect(navigated, isFalse); + expect(errors, isEmpty); + }); + test( + 'disposal invalidates lookup without notifying a disposed screen', + () async { + final pending = Completer(); + final first = run(pending: pending.future); + final disposed = preview; + disposed.dispose(); + preview = SendPreview(); + pending.complete( + const OpenAliasRecipient(domain: 'alice.example', address: 'literal'), + ); + await first; + expect(prepared, isEmpty); + expect(errors, isEmpty); + }, + ); +} From d7e97a18b6be6d947d98f5d7c8d5825de27c3514 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 1 Oct 2026 11:27:58 -0500 Subject: [PATCH 08/17] chore(openalias): remove obsolete lookup dialog Delete the unused lookup/accept dialog and its send-form attribution widget. Confirmation continues to display attribution from the prepared TxData. Remove the seven dialog-specific tests, now replaced by active mobile and desktop tests of direct destination entry and Preview-time resolution. --- lib/widgets/open_alias_dialog.dart | 283 ----------------------- test/widgets/open_alias_dialog_test.dart | 175 -------------- 2 files changed, 458 deletions(-) delete mode 100644 lib/widgets/open_alias_dialog.dart delete mode 100644 test/widgets/open_alias_dialog_test.dart diff --git a/lib/widgets/open_alias_dialog.dart b/lib/widgets/open_alias_dialog.dart deleted file mode 100644 index e681eebf38..0000000000 --- a/lib/widgets/open_alias_dialog.dart +++ /dev/null @@ -1,283 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../services/openalias/open_alias.dart'; -import '../themes/stack_colors.dart'; -import '../utilities/constants.dart'; -import '../utilities/text_styles.dart'; -import '../utilities/util.dart'; -import 'conditional_parent.dart'; -import 'desktop/desktop_dialog.dart'; -import 'desktop/desktop_dialog_close_button.dart'; -import 'desktop/primary_button.dart'; -import 'desktop/secondary_button.dart'; -import 'rounded_container.dart'; -import 'stack_dialog.dart'; -import 'stack_text_field.dart'; - -class OpenAliasDialog extends StatefulWidget { - const OpenAliasDialog({ - super.key, - required this.resolve, - this.initialInput = "", - }); - - final Future Function(String) resolve; - final String initialInput; - - @override - State createState() => _OpenAliasDialogState(); -} - -class _OpenAliasDialogState extends State { - late final TextEditingController _inputController; - late final FocusNode _inputFocusNode; - late String _lastInput; - - OpenAliasRecipient? _result; - String? _error; - bool _busy = false; - int _generation = 0; - - void _onInputChanged() { - // Ignore selection-only notifications. - if (_inputController.text == _lastInput) { - return; - } - _lastInput = _inputController.text; - _generation++; - setState(() { - _busy = false; - _result = null; - _error = null; - }); - } - - Future _lookup() async { - if (_busy || _inputController.text.trim().isEmpty) { - return; - } - final generation = ++_generation; - setState(() { - _busy = true; - _result = null; - _error = null; - }); - try { - final result = await widget.resolve(_inputController.text); - if (mounted && generation == _generation) { - setState(() => _result = result); - } - } catch (e) { - if (mounted && generation == _generation) { - setState( - () => _error = e is OpenAliasException - ? e.message - : "Lookup failed. Please try again.", - ); - } - } finally { - if (mounted && generation == _generation) { - setState(() => _busy = false); - } - } - } - - @override - void initState() { - _inputController = TextEditingController(text: widget.initialInput); - _inputFocusNode = FocusNode(); - _lastInput = _inputController.text; - _inputController.addListener(_onInputChanged); - - super.initState(); - } - - @override - void dispose() { - _generation++; - _inputController.dispose(); - _inputFocusNode.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final isDesktop = Util.isDesktop; - final colors = Theme.of(context).extension()!; - final result = _result; - - final labelStyle = isDesktop - ? STextStyles.desktopTextExtraExtraSmall(context) - : STextStyles.smallMed12(context); - final valueStyle = isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith(color: colors.textDark) - : STextStyles.itemSubtitle12(context); - - return ConditionalParent( - condition: isDesktop, - builder: (child) => DesktopDialog( - maxWidth: 580, - maxHeight: double.infinity, - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.only(left: 32), - child: Text( - "Use OpenAlias", - style: STextStyles.desktopH3(context), - ), - ), - const DesktopDialogCloseButton(), - ], - ), - Padding( - padding: const EdgeInsets.only(left: 32, right: 32, bottom: 32), - child: child, - ), - ], - ), - ), - child: ConditionalParent( - condition: !isDesktop, - builder: (child) => StackDialogBase( - keyboardPaddingAmount: MediaQuery.of(context).viewInsets.bottom, - child: child, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (!isDesktop) - Text("Use OpenAlias", style: STextStyles.pageTitleH2(context)), - if (!isDesktop) const SizedBox(height: 16), - ClipRRect( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - child: TextField( - key: const Key("openAliasInput"), - controller: _inputController, - focusNode: _inputFocusNode, - autofocus: isDesktop, - autocorrect: false, - enableSuggestions: false, - keyboardType: TextInputType.emailAddress, - style: isDesktop - ? STextStyles.desktopTextExtraSmall( - context, - ).copyWith(color: colors.textFieldActiveText, height: 1.8) - : STextStyles.field(context), - decoration: standardInputDecoration( - "Domain or email-style alias", - _inputFocusNode, - context, - desktopMed: isDesktop, - ), - onSubmitted: (_) => _lookup(), - ), - ), - if (_error != null) - Align( - alignment: Alignment.topLeft, - child: Padding( - padding: const EdgeInsets.only(left: 12.0, top: 4.0), - child: Semantics( - liveRegion: true, - child: Text( - _error!, - textAlign: TextAlign.left, - style: STextStyles.label( - context, - ).copyWith(color: colors.textError), - ), - ), - ), - ), - if (result != null) ...[ - const SizedBox(height: 16), - RoundedContainer( - color: colors.textFieldDefaultBG, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text("OpenAlias", style: labelStyle), - const SizedBox(height: 4), - SelectableText(result.domain, style: valueStyle), - const SizedBox(height: 12), - Text("Address", style: labelStyle), - const SizedBox(height: 4), - SelectableText( - result.address, - key: const Key("openAliasResolvedAddress"), - style: valueStyle, - ), - ], - ), - ), - const SizedBox(height: 12), - Text( - "Check the recipient and full address before continuing.", - style: labelStyle, - ), - ], - SizedBox(height: isDesktop ? 32 : 24), - Row( - children: [ - Expanded( - child: SecondaryButton( - label: "Cancel", - buttonHeight: isDesktop ? ButtonHeight.l : null, - onPressed: Navigator.of(context).pop, - ), - ), - const SizedBox(width: 16), - Expanded( - child: result == null - ? PrimaryButton( - label: _busy ? "Looking up..." : "Look up", - buttonHeight: isDesktop ? ButtonHeight.l : null, - enabled: !_busy, - onPressed: _lookup, - ) - : PrimaryButton( - key: const Key("acceptOpenAlias"), - label: "Use address", - buttonHeight: isDesktop ? ButtonHeight.l : null, - onPressed: () => Navigator.of(context).pop(result), - ), - ), - ], - ), - ], - ), - ), - ); - } -} - -class OpenAliasAttribution extends StatelessWidget { - const OpenAliasAttribution({super.key, required this.recipient}); - - final OpenAliasRecipient recipient; - - @override - Widget build(BuildContext context) { - return Align( - alignment: Alignment.topLeft, - child: Padding( - padding: const EdgeInsets.only(left: 12.0, top: 4.0), - child: Text( - "Resolved from OpenAlias ${recipient.domain}", - textAlign: TextAlign.left, - style: STextStyles.label(context).copyWith( - color: Theme.of(context).extension()!.accentColorGreen, - ), - ), - ), - ); - } -} diff --git a/test/widgets/open_alias_dialog_test.dart b/test/widgets/open_alias_dialog_test.dart deleted file mode 100644 index bc0fc524fb..0000000000 --- a/test/widgets/open_alias_dialog_test.dart +++ /dev/null @@ -1,175 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:stackwallet/models/isar/stack_theme.dart'; -import 'package:stackwallet/services/openalias/open_alias.dart'; -import 'package:stackwallet/themes/stack_colors.dart'; -import 'package:stackwallet/utilities/util.dart'; -import 'package:stackwallet/widgets/open_alias_dialog.dart'; - -import '../sample_data/theme_json.dart'; - -const recipient = OpenAliasRecipient( - domain: 'alice.example', - address: 'full-monero-address', -); - -Future open( - WidgetTester tester, - Future Function(String) resolve, - void Function(OpenAliasRecipient?) accepted, -) async { - await tester.pumpWidget( - MaterialApp( - theme: ThemeData( - extensions: [ - StackColors.fromStackColorTheme( - StackTheme.fromJson(json: lightThemeJsonMap), - ), - ], - ), - home: Builder( - builder: (context) => Scaffold( - body: TextButton( - onPressed: () async { - accepted( - await showDialog( - context: context, - builder: (_) => OpenAliasDialog(resolve: resolve), - ), - ); - }, - child: const Text('Open'), - ), - ), - ), - ), - ); - await tester.tap(find.text('Open')); - await tester.pumpAndSettle(); -} - -void main() { - for (final size in [const Size(390, 844), const Size(1280, 900)]) { - testWidgets('explicit lookup and full address acceptance at $size', ( - tester, - ) async { - tester.view.physicalSize = size; - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - // Util.isDesktop is false on Linux below 800 logical pixels. - Util.screenWidth = size.width; - addTearDown(() => Util.screenWidth = null); - var calls = 0; - OpenAliasRecipient? accepted; - await open(tester, (_) async { - calls++; - return recipient; - }, (r) => accepted = r); - await tester.enterText( - find.byKey(const Key('openAliasInput')), - 'alice.example', - ); - await tester.pump(); - expect(calls, 0); - await tester.tap(find.text('Look up')); - await tester.pumpAndSettle(); - expect(find.text('full-monero-address'), findsOneWidget); - expect(accepted, isNull); - await tester.tap(find.text('Use address')); - await tester.pumpAndSettle(); - expect(accepted, same(recipient)); - expect(calls, 1); - expect(tester.takeException(), isNull); - }); - } - testWidgets('edits discard late results and prevent acceptance', ( - tester, - ) async { - final pending = Completer(); - await open(tester, (_) => pending.future, (_) {}); - await tester.enterText( - find.byKey(const Key('openAliasInput')), - 'alice.example', - ); - await tester.tap(find.text('Look up')); - await tester.pump(); - await tester.enterText( - find.byKey(const Key('openAliasInput')), - 'bob.example', - ); - pending.complete(recipient); - await tester.pumpAndSettle(); - expect(find.text('Use address'), findsNothing); - expect(find.text('full-monero-address'), findsNothing); - }); - testWidgets('editing a verified alias invalidates acceptance', ( - tester, - ) async { - await open(tester, (_) async => recipient, (_) {}); - await tester.enterText( - find.byKey(const Key('openAliasInput')), - 'alice.example', - ); - await tester.tap(find.text('Look up')); - await tester.pumpAndSettle(); - await tester.enterText( - find.byKey(const Key('openAliasInput')), - 'bob.example', - ); - await tester.pump(); - expect(find.text('Use address'), findsNothing); - }); - testWidgets('moving the cursor does not discard a verified result', ( - tester, - ) async { - await open(tester, (_) async => recipient, (_) {}); - await tester.enterText( - find.byKey(const Key('openAliasInput')), - 'alice.example', - ); - await tester.tap(find.text('Look up')); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('openAliasInput'))); - await tester.pump(); - expect(find.text('Use address'), findsOneWidget); - }); - testWidgets('failed verification remains visible without an accept action', ( - tester, - ) async { - await open( - tester, - (_) async => throw const OpenAliasException('DNSSEC failed'), - (_) {}, - ); - await tester.enterText( - find.byKey(const Key('openAliasInput')), - 'alice.example', - ); - await tester.tap(find.text('Look up')); - await tester.pumpAndSettle(); - expect(find.text('DNSSEC failed'), findsOneWidget); - expect(find.text('Use address'), findsNothing); - }); - testWidgets('cancel during lookup does not update a disposed dialog', ( - tester, - ) async { - final pending = Completer(); - OpenAliasRecipient? accepted; - await open(tester, (_) => pending.future, (r) => accepted = r); - await tester.enterText( - find.byKey(const Key('openAliasInput')), - 'alice.example', - ); - await tester.tap(find.text('Look up')); - await tester.pump(); - await tester.tap(find.text('Cancel')); - await tester.pumpAndSettle(); - pending.complete(recipient); - await tester.pumpAndSettle(); - expect(accepted, isNull); - expect(tester.takeException(), isNull); - }); -} From adee9d94ad361769ebd1289b7c5104385d5a653f Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 1 Oct 2026 11:56:34 -0500 Subject: [PATCH 09/17] fix(send): prevent overflow in phone-sized OpenAlias flow Select mobile and desktop layouts explicitly in widget tests using a debug-only Util override, restored during teardown. This avoids relying on the Linux-only small-screen exception when the tests run on macOS. Exercise the inline workflow at 390x844 and 1200x900 with a full-length Monero recipient. Constrain the balance columns and remove the unbounded destination-label row exposed by the phone viewport. --- lib/pages/send_view/send_view.dart | 216 +++++++++--------- lib/utilities/util.dart | 13 ++ .../send_view/open_alias_preview_test.dart | 20 +- test/utilities/util_layout_test.dart | 35 +++ 4 files changed, 174 insertions(+), 110 deletions(-) create mode 100644 test/utilities/util_layout_test.dart diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 66a5566bb7..338b27340d 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -1644,106 +1644,118 @@ class _SendViewState extends ConsumerState { height: 22, ), const SizedBox(width: 6), - Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - ref.watch(pWalletName(walletId)), - style: STextStyles.titleBold12( - context, - ).copyWith(fontSize: 14), - overflow: TextOverflow.ellipsis, - maxLines: 1, - ), - // const SizedBox( - // height: 2, - // ), - if (isFiro || isMwebEnabled) - Text( - "${balType.name.capitalize()} balance", - style: STextStyles.label(context) - .copyWith(fontSize: 10), - ), - if (coin is! Firo) + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ Text( - "Available balance", - style: STextStyles.label(context) - .copyWith(fontSize: 10), + ref.watch(pWalletName(walletId)), + style: STextStyles.titleBold12( + context, + ).copyWith(fontSize: 14), + overflow: TextOverflow.ellipsis, + maxLines: 1, ), - ], + // const SizedBox( + // height: 2, + // ), + if (isFiro || isMwebEnabled) + Text( + "${balType.name.capitalize()} balance", + style: STextStyles.label(context) + .copyWith(fontSize: 10), + ), + if (coin is! Firo) + Text( + "Available balance", + style: STextStyles.label(context) + .copyWith(fontSize: 10), + ), + ], + ), ), - const Spacer(), - Builder( - builder: (context) { - final Amount amount; - if (showPrivateBalance) { - switch (balType) { - case BalanceType.public: - amount = ref - .read( - pWalletBalance(walletId), - ) - .spendable; - break; - - case BalanceType.private: - amount = ref - .read( - isMwebEnabled - ? pWalletBalanceSecondary( - walletId, - ) - : pWalletBalanceTertiary( - walletId, - ), - ) - .spendable; - break; + const SizedBox(width: 12), + Expanded( + child: Builder( + builder: (context) { + final Amount amount; + if (showPrivateBalance) { + switch (balType) { + case BalanceType.public: + amount = ref + .read( + pWalletBalance(walletId), + ) + .spendable; + break; + + case BalanceType.private: + amount = ref + .read( + isMwebEnabled + ? pWalletBalanceSecondary( + walletId, + ) + : pWalletBalanceTertiary( + walletId, + ), + ) + .spendable; + break; + } + } else { + amount = ref + .read(pWalletBalance(walletId)) + .spendable; } - } else { - amount = ref - .read(pWalletBalance(walletId)) - .spendable; - } - return GestureDetector( - onTap: () { - cryptoAmountController.text = ref - .read(pAmountFormatter(coin)) - .formatEditable(amount); - }, - child: Container( - color: Colors.transparent, - child: Column( - crossAxisAlignment: - CrossAxisAlignment.end, - children: [ - Text( - ref - .watch( - pAmountFormatter(coin), - ) - .format(amount), - style: - STextStyles.titleBold12( - context, - ).copyWith(fontSize: 10), - textAlign: TextAlign.right, - ), - if (price != null) + return GestureDetector( + onTap: () { + cryptoAmountController.text = ref + .read(pAmountFormatter(coin)) + .formatEditable(amount); + }, + child: Container( + color: Colors.transparent, + child: Column( + crossAxisAlignment: + CrossAxisAlignment.end, + children: [ Text( - "${(amount.decimal * price).toAmount(fractionDigits: 2).fiatString(locale: locale)} ${ref.watch(prefsChangeNotifierProvider.select((value) => value.currency))}", - style: STextStyles.subtitle( - context, - ).copyWith(fontSize: 8), + ref + .watch( + pAmountFormatter( + coin, + ), + ) + .format(amount), + style: + STextStyles.titleBold12( + context, + ).copyWith( + fontSize: 10, + ), textAlign: TextAlign.right, ), - ], + if (price != null) + Text( + "${(amount.decimal * price).toAmount(fractionDigits: 2).fiatString(locale: locale)} ${ref.watch(prefsChangeNotifierProvider.select((value) => value.currency))}", + style: + STextStyles.subtitle( + context, + ).copyWith( + fontSize: 8, + ), + textAlign: + TextAlign.right, + ), + ], + ), ), - ), - ); - }, + ); + }, + ), ), ], ), @@ -1770,20 +1782,14 @@ class _SendViewState extends ConsumerState { ], if (!isSlatepackMode) - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Text( - isPaynymSend - ? "Send to PayNym address" - : coin is Monero - ? "Send to address or OpenAlias" - : "Send to", - style: STextStyles.smallMed12(context), - textAlign: TextAlign.left, - ), - ], + Text( + isPaynymSend + ? "Send to PayNym address" + : coin is Monero + ? "Send to address or OpenAlias" + : "Send to", + style: STextStyles.smallMed12(context), + textAlign: TextAlign.left, ), if (!isSlatepackMode) const SizedBox(height: 8), if (isPaynymSend) diff --git a/lib/utilities/util.dart b/lib/utilities/util.dart index 1e3f612520..53fd0e5970 100644 --- a/lib/utilities/util.dart +++ b/lib/utilities/util.dart @@ -33,6 +33,10 @@ abstract class Util { static double? screenWidth; static bool? isIpad; + /// Overrides layout selection in debug builds for host-independent tests. + @visibleForTesting + static bool? debugIsDesktopOverride; + static NumberSymbols? getSymbolsFor({required String locale}) { return _numberSymbolsCache.putIfAbsent(locale, () { final exactSymbols = numberFormatSymbols[locale]; @@ -84,6 +88,15 @@ abstract class Util { } static bool get isDesktop { + bool? override; + assert(() { + override = debugIsDesktopOverride; + return true; + }()); + if (override != null) { + return override!; + } + // special check for running on linux based phones if (Platform.isLinux && screenWidth != null && screenWidth! < 800) { return false; diff --git a/test/pages/send_view/open_alias_preview_test.dart b/test/pages/send_view/open_alias_preview_test.dart index f3fb1db9df..a2b3b3695d 100644 --- a/test/pages/send_view/open_alias_preview_test.dart +++ b/test/pages/send_view/open_alias_preview_test.dart @@ -34,7 +34,9 @@ import 'package:stackwallet/wallets/wallet/wallet.dart'; import '../../sample_data/theme_json.dart'; -const literal = 'literal-monero-recipient'; +const literal = + '4AeRgkWZsMJhAWKMeCZ3h4ZSPnAcW5VBtRFyLd6gBEf6GgJU2FH' + 'XDA6i1DnQTd6h8R3VU5AkbGcWSNhtSwNNPgaD48gp4nn'; Amount amount(int value) => Amount(rawValue: BigInt.from(value), fractionDigits: 12); @@ -162,12 +164,20 @@ class _Harness { WidgetTester tester, { SendViewAutoFillData? autofill, }) async { - tester.view.physicalSize = Size(desktop ? 1200 : 600, 1600); + final size = desktop ? const Size(1200, 900) : const Size(390, 844); + final previousLayout = Util.debugIsDesktopOverride; + final previousWidth = Util.screenWidth; + Util.debugIsDesktopOverride = desktop; + tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; - Util.screenWidth = desktop ? 1200 : 600; + Util.screenWidth = size.width; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - addTearDown(() => Util.screenWidth = null); + addTearDown(() { + Util.debugIsDesktopOverride = previousLayout; + Util.screenWidth = previousWidth; + }); + expect(Util.isDesktop, desktop); final theme = StackTheme.fromJson(json: lightThemeJsonMap); container = ProviderContainer( overrides: [ @@ -317,7 +327,7 @@ class _Harness { void main() { for (final desktop in [false, true]) { - final layout = desktop ? 'desktop' : 'mobile'; + final layout = desktop ? 'desktop 1200x900' : 'mobile 390x844'; testWidgets( '$layout resolves on Preview and shows each prepared recipient', (tester) async { diff --git a/test/utilities/util_layout_test.dart b/test/utilities/util_layout_test.dart new file mode 100644 index 0000000000..60d1920236 --- /dev/null +++ b/test/utilities/util_layout_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/utilities/util.dart'; + +void main() { + test( + 'explicit layout overrides host, viewport width, and iPad detection', + () { + final previousLayout = Util.debugIsDesktopOverride; + final previousWidth = Util.screenWidth; + final previousIpad = Util.isIpad; + addTearDown(() { + Util.debugIsDesktopOverride = previousLayout; + Util.screenWidth = previousWidth; + Util.isIpad = previousIpad; + }); + + for (final width in [390.0, 1200.0]) { + for (final ipad in [false, true]) { + Util.screenWidth = width; + Util.isIpad = ipad; + Util.debugIsDesktopOverride = null; + final detectedLayout = Util.isDesktop; + + Util.debugIsDesktopOverride = false; + expect(Util.isDesktop, isFalse); + Util.debugIsDesktopOverride = true; + expect(Util.isDesktop, isTrue); + + Util.debugIsDesktopOverride = null; + expect(Util.isDesktop, detectedLayout); + } + } + }, + ); +} From 4fe9834418ac3cc6b8f1110ff31f95ec857429c8 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 1 Oct 2026 14:32:18 -0500 Subject: [PATCH 10/17] fix(openalias): accept underscores in DNS labels Aliases and CNAME targets are DNS names, not hostnames, and may contain underscores (RFC 2181). A CNAME to a name such as _oa.pay.example previously failed with the input validation message. --- lib/services/openalias/open_alias.dart | 3 ++- .../services/openalias/doh_open_alias_test.dart | 17 +++++++++++++++++ test/services/openalias/open_alias_test.dart | 1 + 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/lib/services/openalias/open_alias.dart b/lib/services/openalias/open_alias.dart index 9633923558..018cda2932 100644 --- a/lib/services/openalias/open_alias.dart +++ b/lib/services/openalias/open_alias.dart @@ -27,7 +27,8 @@ String normalizeOpenAlias(String input) { } name = name.replaceAll('@', '.'); if (name.endsWith('.')) name = name.substring(0, name.length - 1); - final label = RegExp(r'^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$'); + // Underscores are valid in DNS names (RFC 2181), e.g. _service labels. + final label = RegExp(r'^[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?$'); if (name.length > 253 || !name.contains('.') || !name.split('.').every(label.hasMatch) || diff --git a/test/services/openalias/doh_open_alias_test.dart b/test/services/openalias/doh_open_alias_test.dart index 670b51e47a..0d33835caf 100644 --- a/test/services/openalias/doh_open_alias_test.dart +++ b/test/services/openalias/doh_open_alias_test.dart @@ -85,6 +85,23 @@ void main() { ); expect(select(records).address, 'valid'); }); + test('follows CNAMEs to underscore names', () { + final records = decodeAuthenticatedDns( + reply( + answers: [ + { + 'name': 'alice.example.', + 'type': 5, + 'TTL': 60, + 'data': '_oa.pay.example.', + }, + txt('_oa.pay.example.', 'oa1:xmr recipient_address=valid;'), + ], + ), + 'alice.example', + ); + expect(select(records).address, 'valid'); + }); test('rejects CNAME loops and CNAME/TXT ambiguity', () { for (final answers in [ [ diff --git a/test/services/openalias/open_alias_test.dart b/test/services/openalias/open_alias_test.dart index 279c17a12a..c782493d1e 100644 --- a/test/services/openalias/open_alias_test.dart +++ b/test/services/openalias/open_alias_test.dart @@ -14,6 +14,7 @@ void main() { normalizeOpenAlias('xn--bcher-kva.example'), 'xn--bcher-kva.example', ); + expect(normalizeOpenAlias('Dan_M@_Pay.Example'), 'dan_m._pay.example'); }); for (final name in [ 'example', From 239b0bf9f2fa6daa0463a161176c82979d4723a8 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 1 Oct 2026 14:32:45 -0500 Subject: [PATCH 11/17] fix(openalias): report malformed answer names as invalid responses Names in a resolver answer were checked with the alias input validator, so a malformed CNAME target told the user to enter a valid domain. --- lib/services/openalias/doh_open_alias.dart | 6 ++++- .../openalias/doh_open_alias_test.dart | 24 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/lib/services/openalias/doh_open_alias.dart b/lib/services/openalias/doh_open_alias.dart index 900371075a..49a0bb429a 100644 --- a/lib/services/openalias/doh_open_alias.dart +++ b/lib/services/openalias/doh_open_alias.dart @@ -69,7 +69,11 @@ String _dnsName(Object? value) { if (value is! String || value.contains('@')) { throw const OpenAliasException('Invalid DNS response.'); } - return normalizeOpenAlias(value); + try { + return normalizeOpenAlias(value); + } on OpenAliasException { + throw const OpenAliasException('Invalid DNS response.'); + } } List decodeAuthenticatedDns(Object? body, String domain) { diff --git a/test/services/openalias/doh_open_alias_test.dart b/test/services/openalias/doh_open_alias_test.dart index 0d33835caf..51f025c39f 100644 --- a/test/services/openalias/doh_open_alias_test.dart +++ b/test/services/openalias/doh_open_alias_test.dart @@ -102,6 +102,30 @@ void main() { ); expect(select(records).address, 'valid'); }); + test('reports malformed answer names as invalid responses', () { + expect( + () => decodeAuthenticatedDns( + reply( + answers: [ + { + 'name': 'alice.example.', + 'type': 5, + 'TTL': 60, + 'data': 'pay..example.', + }, + ], + ), + 'alice.example', + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Invalid DNS response.', + ), + ), + ); + }); test('rejects CNAME loops and CNAME/TXT ambiguity', () { for (final answers in [ [ From 3ebec447292b50f1f3dd654211aad128c096127f Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 1 Oct 2026 14:34:02 -0500 Subject: [PATCH 12/17] refactor(openalias): name DoH timeouts and allow overrides Replace the inline connection and lookup durations with named constants, and let callers override them per DohOpenAlias instance. --- lib/services/openalias/dns_proxy_connection.dart | 4 +++- lib/services/openalias/doh_open_alias.dart | 13 +++++++++++-- lib/services/openalias/open_alias_service.dart | 2 +- test/services/openalias/doh_transport_test.dart | 4 ++-- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/lib/services/openalias/dns_proxy_connection.dart b/lib/services/openalias/dns_proxy_connection.dart index c8760226d3..3235cd6d44 100644 --- a/lib/services/openalias/dns_proxy_connection.dart +++ b/lib/services/openalias/dns_proxy_connection.dart @@ -6,6 +6,8 @@ import 'package:async/async.dart'; import 'dns_proxy_socket.dart'; class DnsProxyConnection { + static const proxyConnectTimeout = Duration(seconds: 5); + RawSocket? _transport; DnsProxySocket? _socket; final _result = Completer(); @@ -43,7 +45,7 @@ class DnsProxyConnection { final raw = await RawSocket.connect( host, port, - timeout: const Duration(seconds: 5), + timeout: proxyConnectTimeout, ); _transport = raw; if (_cancelled) throw const SocketException('DNS connection cancelled'); diff --git a/lib/services/openalias/doh_open_alias.dart b/lib/services/openalias/doh_open_alias.dart index 49a0bb429a..da213ee48a 100644 --- a/lib/services/openalias/doh_open_alias.dart +++ b/lib/services/openalias/doh_open_alias.dart @@ -5,11 +5,20 @@ import 'dns_proxy_connection.dart'; import 'open_alias.dart'; class DohOpenAlias { + static const directConnectTimeout = Duration(seconds: 5); + static const directLookupTimeout = Duration(seconds: 12); + + final Duration? connectTimeout; + final Duration? lookupTimeout; + + const DohOpenAlias({this.connectTimeout, this.lookupTimeout}); + Future> lookup( String domain, { required ({InternetAddress host, int port})? proxyInfo, }) async { - final client = HttpClient()..connectionTimeout = const Duration(seconds: 5); + final client = HttpClient() + ..connectionTimeout = connectTimeout ?? directConnectTimeout; final tunnels = []; try { if (proxyInfo != null) { @@ -22,7 +31,7 @@ class DohOpenAlias { return await _request( client, domain, - ).timeout(const Duration(seconds: 12)); + ).timeout(lookupTimeout ?? directLookupTimeout); } on OpenAliasException { rethrow; } catch (_) { diff --git a/lib/services/openalias/open_alias_service.dart b/lib/services/openalias/open_alias_service.dart index ac19b2400f..b43c1bf9db 100644 --- a/lib/services/openalias/open_alias_service.dart +++ b/lib/services/openalias/open_alias_service.dart @@ -22,7 +22,7 @@ class OpenAliasService { _lookup = lookup ?? _lookupDns; static Future> _lookupDns(String domain, bool useTor) => - DohOpenAlias().lookup( + const DohOpenAlias().lookup( domain, proxyInfo: useTor ? TorService.sharedInstance.getProxyInfo() : null, ); diff --git a/test/services/openalias/doh_transport_test.dart b/test/services/openalias/doh_transport_test.dart index 93c6f6d6e2..87f89a785a 100644 --- a/test/services/openalias/doh_transport_test.dart +++ b/test/services/openalias/doh_transport_test.dart @@ -127,7 +127,7 @@ void main() { final proxy = await _StalledTlsProxy.start(); addTearDown(proxy.close); final failed = expectLater( - DohOpenAlias().lookup( + const DohOpenAlias().lookup( 'alice.example', proxyInfo: ( host: InternetAddress.loopbackIPv4, @@ -194,7 +194,7 @@ void main() { }); try { await expectLater( - DohOpenAlias().lookup( + const DohOpenAlias().lookup( 'alice.example', proxyInfo: (host: InternetAddress.loopbackIPv4, port: server.port), ), From 4db332f7efd14a48e7d053445ac4963aea0a4464 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 1 Oct 2026 14:34:29 -0500 Subject: [PATCH 13/17] fix(openalias): allow Tor lookups more time to connect Over Tor, the connection deadline covers the local SOCKS connect, the exit stream to the resolver and the TLS handshake through Tor, so five seconds was tight. Tor lookups now get 10 s to connect and 20 s overall; direct lookups keep 5 s and 12 s. The overall deadline grows with the connection deadline so a slow connect still leaves time for the response. --- lib/services/openalias/doh_open_alias.dart | 14 +++++++++----- test/services/openalias/doh_transport_test.dart | 4 ++-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/lib/services/openalias/doh_open_alias.dart b/lib/services/openalias/doh_open_alias.dart index da213ee48a..e927a46cd1 100644 --- a/lib/services/openalias/doh_open_alias.dart +++ b/lib/services/openalias/doh_open_alias.dart @@ -7,6 +7,9 @@ import 'open_alias.dart'; class DohOpenAlias { static const directConnectTimeout = Duration(seconds: 5); static const directLookupTimeout = Duration(seconds: 12); + // Over Tor the connection also waits for the exit stream and TLS via Tor. + static const torConnectTimeout = Duration(seconds: 10); + static const torLookupTimeout = Duration(seconds: 20); final Duration? connectTimeout; final Duration? lookupTimeout; @@ -17,8 +20,10 @@ class DohOpenAlias { String domain, { required ({InternetAddress host, int port})? proxyInfo, }) async { + final viaTor = proxyInfo != null; final client = HttpClient() - ..connectionTimeout = connectTimeout ?? directConnectTimeout; + ..connectionTimeout = + connectTimeout ?? (viaTor ? torConnectTimeout : directConnectTimeout); final tunnels = []; try { if (proxyInfo != null) { @@ -28,10 +33,9 @@ class DohOpenAlias { return ConnectionTask.fromSocket(task.socket, task.cancel); }; } - return await _request( - client, - domain, - ).timeout(lookupTimeout ?? directLookupTimeout); + return await _request(client, domain).timeout( + lookupTimeout ?? (viaTor ? torLookupTimeout : directLookupTimeout), + ); } on OpenAliasException { rethrow; } catch (_) { diff --git a/test/services/openalias/doh_transport_test.dart b/test/services/openalias/doh_transport_test.dart index 87f89a785a..32d23f0dbf 100644 --- a/test/services/openalias/doh_transport_test.dart +++ b/test/services/openalias/doh_transport_test.dart @@ -127,7 +127,7 @@ void main() { final proxy = await _StalledTlsProxy.start(); addTearDown(proxy.close); final failed = expectLater( - const DohOpenAlias().lookup( + const DohOpenAlias(lookupTimeout: Duration(seconds: 1)).lookup( 'alice.example', proxyInfo: ( host: InternetAddress.loopbackIPv4, @@ -138,7 +138,7 @@ void main() { ); final peer = await proxy.nextPeer(); await peer.clientHello.future.timeout(const Duration(seconds: 2)); - await failed.timeout(const Duration(seconds: 8)); + await failed.timeout(const Duration(seconds: 4)); await peer.disconnected.future.timeout(const Duration(seconds: 2)); }); From f34b18b6a88197f1f763ee5e0a9a09535f65c886 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 1 Oct 2026 14:35:22 -0500 Subject: [PATCH 14/17] feat(send): show the OpenAlias as entered on confirmation The confirmation showed the DNS name used for the lookup, so dan@cypherstack.com appeared as dan.cypherstack.com. Keep the trimmed, lowercased input for display; lookups and validation still use the DNS name. --- lib/pages/send_view/confirm_transaction_view.dart | 4 ++-- lib/services/openalias/open_alias.dart | 13 ++++++++++++- lib/services/openalias/send_preview.dart | 9 ++++++++- test/pages/send_view/open_alias_preview_test.dart | 5 +++-- test/services/openalias/open_alias_test.dart | 3 +++ test/services/openalias/send_preview_test.dart | 1 + 6 files changed, 29 insertions(+), 6 deletions(-) diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index 672bf9c726..a64aeada68 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -788,7 +788,7 @@ class _ConfirmTransactionViewState ), const SizedBox(height: 4), Text( - widget.txData.openAliasRecipient!.domain, + widget.txData.openAliasRecipient!.displayAlias, style: STextStyles.itemSubtitle12(context), ), ], @@ -1173,7 +1173,7 @@ class _ConfirmTransactionViewState ), const SizedBox(height: 2), SelectableText( - widget.txData.openAliasRecipient!.domain, + widget.txData.openAliasRecipient!.displayAlias, style: STextStyles.desktopTextExtraExtraSmall( context, diff --git a/lib/services/openalias/open_alias.dart b/lib/services/openalias/open_alias.dart index 018cda2932..db97d709e7 100644 --- a/lib/services/openalias/open_alias.dart +++ b/lib/services/openalias/open_alias.dart @@ -1,8 +1,19 @@ class OpenAliasRecipient { final String domain; final String address; + // The alias as the user entered it, e.g. user@example.com. + final String displayAlias; - const OpenAliasRecipient({required this.domain, required this.address}); + const OpenAliasRecipient({ + required this.domain, + required this.address, + String? displayAlias, + }) : displayAlias = displayAlias ?? domain; +} + +String displayOpenAlias(String input) { + final name = input.trim().toLowerCase(); + return name.endsWith('.') ? name.substring(0, name.length - 1) : name; } class OpenAliasException implements Exception { diff --git a/lib/services/openalias/send_preview.dart b/lib/services/openalias/send_preview.dart index 07df38ce49..667dfb6b2a 100644 --- a/lib/services/openalias/send_preview.dart +++ b/lib/services/openalias/send_preview.dart @@ -126,7 +126,14 @@ class SendPreviewAttempt { 'OpenAlias did not return a valid recipient for this wallet.', ); } - return ResolvedSendRecipient(recipient.address, recipient); + return ResolvedSendRecipient( + recipient.address, + OpenAliasRecipient( + domain: recipient.domain, + address: recipient.address, + displayAlias: displayOpenAlias(source), + ), + ); } } diff --git a/test/pages/send_view/open_alias_preview_test.dart b/test/pages/send_view/open_alias_preview_test.dart index a2b3b3695d..8945d0b1bf 100644 --- a/test/pages/send_view/open_alias_preview_test.dart +++ b/test/pages/send_view/open_alias_preview_test.dart @@ -352,8 +352,9 @@ void main() { h.wallet.prepared.single.openAliasRecipient!.domain, 'alice.example', ); + expect(find.text('alice.example'), findsNothing); expect(find.byType(ConfirmTransactionView), findsOneWidget); - expect(find.text('alice.example'), findsOneWidget); + expect(find.text('alice@example'), findsOneWidget); expect(find.text(literal), findsOneWidget); h.navigator.currentState!.pop(); await tester.pumpAndSettle(); @@ -367,7 +368,7 @@ void main() { 'second-recipient', ); expect(find.text('second-recipient'), findsOneWidget); - expect(find.text('alice.example'), findsOneWidget); + expect(find.text('alice@example'), findsOneWidget); // Final send receives the prepared transaction, without another lookup. h.auth.approveNext = true; await tester.ensureVisible(find.text('Send')); diff --git a/test/services/openalias/open_alias_test.dart b/test/services/openalias/open_alias_test.dart index c782493d1e..a751d5f2a4 100644 --- a/test/services/openalias/open_alias_test.dart +++ b/test/services/openalias/open_alias_test.dart @@ -16,6 +16,9 @@ void main() { ); expect(normalizeOpenAlias('Dan_M@_Pay.Example'), 'dan_m._pay.example'); }); + test('displays aliases as entered, without case or root dot', () { + expect(displayOpenAlias(' Dan@CypherStack.com. '), 'dan@cypherstack.com'); + }); for (final name in [ 'example', 'https://example.org', diff --git a/test/services/openalias/send_preview_test.dart b/test/services/openalias/send_preview_test.dart index 793cd60ae4..a2fd7f8245 100644 --- a/test/services/openalias/send_preview_test.dart +++ b/test/services/openalias/send_preview_test.dart @@ -64,6 +64,7 @@ void main() { expect(calls, 2); expect(prepared.map((e) => e.destination), ['literal', 'literal']); expect(prepared.last.alias!.domain, 'alice.example'); + expect(prepared.last.alias!.displayAlias, 'alice@example'); expect(errors, isEmpty); }, ); From 080fd0086642c10d9c8e4d8316208a96345e23f4 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 1 Oct 2026 14:36:04 -0500 Subject: [PATCH 15/17] fix(send): title OpenAlias lookup failures separately A failed alias lookup opened a "Transaction failed" dialog although no transaction was built. Lookup failures now use "OpenAlias lookup failed"; preparation failures keep the existing title. --- lib/widgets/transaction_preview_dialog.dart | 12 +++++++----- test/pages/send_view/open_alias_preview_test.dart | 4 +++- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/lib/widgets/transaction_preview_dialog.dart b/lib/widgets/transaction_preview_dialog.dart index a8fb2a1ab8..0346ce248e 100644 --- a/lib/widgets/transaction_preview_dialog.dart +++ b/lib/widgets/transaction_preview_dialog.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import '../services/openalias/open_alias.dart'; import '../themes/stack_colors.dart'; import '../utilities/text_styles.dart'; import 'desktop/desktop_dialog.dart'; @@ -51,6 +52,10 @@ void showTransactionFailedDialog( Object error, { required bool isDesktop, }) { + // No transaction was built when the recipient could not be resolved. + final title = error is OpenAliasException + ? 'OpenAlias lookup failed' + : 'Transaction failed'; unawaited( showDialog( context: context, @@ -58,7 +63,7 @@ void showTransactionFailedDialog( builder: (context) { if (!isDesktop) { return StackDialog( - title: 'Transaction failed', + title: title, message: error.toString(), rightButton: TextButton( style: Theme.of(context) @@ -87,10 +92,7 @@ void showTransactionFailedDialog( Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - 'Transaction failed', - style: STextStyles.desktopH3(context), - ), + Text(title, style: STextStyles.desktopH3(context)), const DesktopDialogCloseButton(), ], ), diff --git a/test/pages/send_view/open_alias_preview_test.dart b/test/pages/send_view/open_alias_preview_test.dart index 8945d0b1bf..ed491554fc 100644 --- a/test/pages/send_view/open_alias_preview_test.dart +++ b/test/pages/send_view/open_alias_preview_test.dart @@ -400,7 +400,8 @@ void main() { await h.finish(tester); expect(h.wallet.prepared, isEmpty); expect(find.byType(ConfirmTransactionView), findsNothing); - expect(find.text('Transaction failed'), findsOneWidget); + expect(find.text('OpenAlias lookup failed'), findsOneWidget); + expect(find.text('Transaction failed'), findsNothing); expect(find.text('DNSSEC verification failed.'), findsOneWidget); await tester.tap(find.text('Ok')); await tester.pumpAndSettle(); @@ -446,6 +447,7 @@ void main() { expect(h.wallet.prepared, isEmpty); expect(find.byType(ConfirmTransactionView), findsNothing); expect(find.text('Transaction failed'), findsNothing); + expect(find.text('OpenAlias lookup failed'), findsNothing); expect(h.navigator.currentState!.canPop(), isTrue); }); } From 83f5a098575d4aaddbc6d12112578dab50cde9c8 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 1 Oct 2026 14:36:37 -0500 Subject: [PATCH 16/17] fix(openalias): point the external-lookup error at Stack Experience The error asked users to enable external calls in privacy settings, which has no such control. External calls follow the Stack Experience choice under Advanced settings, so name that setting instead. --- lib/services/openalias/open_alias_service.dart | 5 +++-- .../openalias/open_alias_service_test.dart | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/lib/services/openalias/open_alias_service.dart b/lib/services/openalias/open_alias_service.dart index b43c1bf9db..318a49394e 100644 --- a/lib/services/openalias/open_alias_service.dart +++ b/lib/services/openalias/open_alias_service.dart @@ -34,8 +34,9 @@ class OpenAliasService { final domain = normalizeOpenAlias(input); if (!_externalCalls()) { throw const OpenAliasException( - 'OpenAlias requires external lookups. ' - 'Enable external calls in privacy settings to continue.', + 'OpenAlias lookups are off in Incognito mode. To use OpenAlias, ' + 'switch ${AppConfig.prefix} Experience to Easy Crypto in Advanced ' + 'settings.', ); } final useTor = _useTor(); diff --git a/test/services/openalias/open_alias_service_test.dart b/test/services/openalias/open_alias_service_test.dart index 2d9c97de60..03232b0313 100644 --- a/test/services/openalias/open_alias_service_test.dart +++ b/test/services/openalias/open_alias_service_test.dart @@ -24,6 +24,22 @@ void main() { } expect(calls, 0); }); + test('Incognito mode points to the setting that allows lookups', () async { + final service = OpenAliasService( + externalCalls: () => false, + lookup: (_, _) async => [], + ); + await expectLater( + service.resolve('alice.example', validateAddress: (_) => true), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Experience to Easy Crypto in Advanced settings'), + ), + ), + ); + }); test( 'Tor policy is passed to the lookup and a changed policy rejects results', () async { From cca8fed38b73a0a96a5136ee0462522db0df58e1 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 1 Oct 2026 14:37:08 -0500 Subject: [PATCH 17/17] fix(openalias): name the alias in missing-record errors Include the DNS name that was looked up when no record exists, so a typo such as dan@cypherstack (dan.cypherstack) is visible in the error. Report a nonexistent name separately from other resolver failures. --- lib/services/openalias/doh_open_alias.dart | 7 +++++-- lib/services/openalias/open_alias.dart | 4 +++- .../openalias/doh_open_alias_test.dart | 20 +++++++++++++++++++ test/services/openalias/open_alias_test.dart | 12 +++++++++++ 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/lib/services/openalias/doh_open_alias.dart b/lib/services/openalias/doh_open_alias.dart index e927a46cd1..a3f65781c0 100644 --- a/lib/services/openalias/doh_open_alias.dart +++ b/lib/services/openalias/doh_open_alias.dart @@ -93,9 +93,12 @@ List decodeAuthenticatedDns(Object? body, String domain) { if (body is! Map) { throw const OpenAliasException('Invalid DNS response.'); } + if (body['Status'] == 3) { + throw OpenAliasException('No OpenAlias record exists for $domain.'); + } if (body['Status'] != 0) { - throw const OpenAliasException( - 'DNS lookup failed or no record exists. No address was accepted.', + throw OpenAliasException( + 'The DNS lookup for $domain failed. No address was accepted.', ); } if (body['AD'] != true || body['CD'] != false || body['TC'] != false) { diff --git a/lib/services/openalias/open_alias.dart b/lib/services/openalias/open_alias.dart index db97d709e7..9b84f9c81a 100644 --- a/lib/services/openalias/open_alias.dart +++ b/lib/services/openalias/open_alias.dart @@ -139,7 +139,9 @@ OpenAliasRecipient selectOpenAliasRecipient({ addresses.add(address); } if (addresses.isEmpty) { - throw const OpenAliasException('No Monero OpenAlias record was found.'); + throw OpenAliasException( + 'No Monero OpenAlias record was found for $domain.', + ); } if (addresses.length != 1) { throw const OpenAliasException( diff --git a/test/services/openalias/doh_open_alias_test.dart b/test/services/openalias/doh_open_alias_test.dart index 51f025c39f..f229a827f9 100644 --- a/test/services/openalias/doh_open_alias_test.dart +++ b/test/services/openalias/doh_open_alias_test.dart @@ -102,6 +102,26 @@ void main() { ); expect(select(records).address, 'valid'); }); + test('names the alias when no record exists or the lookup fails', () { + for (final (status, message) in [ + (3, 'No OpenAlias record exists for alice.example.'), + (2, 'The DNS lookup for alice.example failed. No address was accepted.'), + ]) { + expect( + () => decodeAuthenticatedDns({ + ...reply(), + 'Status': status, + }, 'alice.example'), + throwsA( + isA().having( + (e) => e.message, + 'message', + message, + ), + ), + ); + } + }); test('reports malformed answer names as invalid responses', () { expect( () => decodeAuthenticatedDns( diff --git a/test/services/openalias/open_alias_test.dart b/test/services/openalias/open_alias_test.dart index a751d5f2a4..4d0a22d030 100644 --- a/test/services/openalias/open_alias_test.dart +++ b/test/services/openalias/open_alias_test.dart @@ -16,6 +16,18 @@ void main() { ); expect(normalizeOpenAlias('Dan_M@_Pay.Example'), 'dan_m._pay.example'); }); + test('names the alias when it has no Monero record', () { + expect( + () => select(['oa1:btc recipient_address=valid;']), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'No Monero OpenAlias record was found for alice.example.', + ), + ), + ); + }); test('displays aliases as entered, without case or root dot', () { expect(displayOpenAlias(' Dan@CypherStack.com. '), 'dan@cypherstack.com'); });