From 52cf3509f7f251aeccccfcefd0d05aa39995f6ec Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 28 Sep 2026 12:42:51 -0500 Subject: [PATCH 01/32] 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 111aa58abb954929bfe6d8218ddc40f36068837e Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 28 Sep 2026 12:54:38 -0500 Subject: [PATCH 02/32] 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 3ecfff64f19f3fc69c2197d7af82a345740a478d Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 28 Sep 2026 12:54:38 -0500 Subject: [PATCH 03/32] 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 | 54 ++++ 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, 465 insertions(+), 23 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 a4886bae84..aa34affcf5 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -788,6 +788,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( @@ -1145,6 +1164,41 @@ class _ConfirmTransactionViewState ], ), ), + 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, diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index e6249006a7..2b57454414 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; @@ -1096,6 +1135,7 @@ class _SendViewState extends ConsumerState { txDataFuture = wallet.prepareSend( txData: TxData( xelisSendAll: coin is Xelis && _xelisSendAll, + openAliasRecipient: _acceptedOpenAlias, recipients: [ TxRecipient( address: _address!, @@ -1205,6 +1245,7 @@ class _SendViewState extends ConsumerState { } void clearSendForm() { + _openAlias = null; if (!mounted) { return; } @@ -1330,6 +1371,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(); @@ -1721,21 +1767,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), @@ -2079,6 +2115,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 b411775efe..93401318b7 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; @@ -723,6 +762,7 @@ class _DesktopSendState extends ConsumerState { txDataFuture = wallet.prepareSend( txData: TxData( xelisSendAll: coin is Xelis && _xelisSendAll, + openAliasRecipient: _acceptedOpenAlias, recipients: [ TxRecipient( address: _address!, @@ -859,6 +899,7 @@ class _DesktopSendState extends ConsumerState { } void clearSendForm() { + _openAlias = null; if (!mounted) { return; } @@ -1283,6 +1324,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(); @@ -1802,14 +1848,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) && @@ -2054,6 +2107,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 a2a195a73a..0944920d2f 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'; @@ -54,6 +55,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; @@ -133,6 +140,7 @@ class TxData { this.noteOnChain, this.memo, this.recipients, + this._openAliasRecipient, this.utxos, this.usedUTXOs, this.changeAddress, @@ -282,6 +290,7 @@ class TxData { Set? utxos, List? usedUTXOs, List? recipients, + OpenAliasRecipient? openAliasRecipient, String? frostMSConfig, List? frostSigners, String? changeAddress, @@ -336,6 +345,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 bbf6d300aba5018c4176702c8716793e96041119 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 29 Sep 2026 10:10:47 -0500 Subject: [PATCH 04/32] 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 7a566b50024ee4cdfe6dc3f38b3473b3e08ef473 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 1 Oct 2026 10:41:46 -0500 Subject: [PATCH 05/32] 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 cca51feb758510c7c033d655af78c22626520157 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 1 Oct 2026 11:09:31 -0500 Subject: [PATCH 06/32] 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 2b4932e3d23f6fe6b70c758c849f52beb266e161 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 1 Oct 2026 11:26:50 -0500 Subject: [PATCH 07/32] 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. Transactions prepared for a stale attempt are still released with cancelSend. 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 | 290 +++++----- .../building_transaction_dialog.dart | 11 +- .../wallet_view/sub_widgets/desktop_send.dart | 330 ++++++------ .../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 | 509 ++++++++++++++++++ .../send_preview_eligibility_test.dart | 73 +++ .../services/openalias/send_preview_test.dart | 172 ++++++ 9 files changed, 1404 insertions(+), 332 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 2b57454414..55b3128bcc 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; @@ -891,9 +893,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)!; @@ -966,6 +1000,7 @@ class _SendViewState extends ConsumerState { ); } + attempt.checkCurrent(); if (shouldSendAll == null || shouldSendAll == false) { // cancel preview return; @@ -974,38 +1009,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( @@ -1039,7 +1078,7 @@ class _SendViewState extends ConsumerState { txData: TxData( sparkRecipients: [ ( - address: _address!, + address: destination, amount: amount, memo: memoController.text, isChange: false, @@ -1057,11 +1096,11 @@ class _SendViewState extends ConsumerState { txData: TxData( recipients: [ TxRecipient( - address: _address!, + address: destination, amount: amount, isChange: false, addressType: wallet.cryptoCurrency.getAddressType( - _address!, + destination, )!, ), ], @@ -1083,18 +1122,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, @@ -1112,10 +1151,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, @@ -1135,13 +1174,13 @@ class _SendViewState extends ConsumerState { txDataFuture = wallet.prepareSend( txData: TxData( xelisSendAll: coin is Xelis && _xelisSendAll, - 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, @@ -1163,12 +1202,12 @@ class _SendViewState extends ConsumerState { TxData txData = results.first as TxData; - if (wasCancelled || !mounted) { + if (!attempt.isCurrent || !mounted) { await wallet.cancelSend(txData: txData); return; } - if (!wasCancelled && mounted) { + if (mounted) { if (isPaynymSend) { txData = txData.copyWith( paynymAccountLite: widget.accountLite!, @@ -1181,8 +1220,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( @@ -1205,47 +1243,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; } @@ -1371,11 +1375,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(); @@ -1383,6 +1382,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); @@ -1455,8 +1468,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(); @@ -1484,6 +1505,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), @@ -1499,7 +1530,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; @@ -1763,15 +1801,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), @@ -2083,7 +2118,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; @@ -2115,10 +2151,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 93401318b7..c2e16d5ad8 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; @@ -588,41 +621,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)); @@ -630,6 +653,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; @@ -663,7 +698,7 @@ class _DesktopSendState extends ConsumerState { txData: TxData( sparkRecipients: [ ( - address: _address!, + address: destination, amount: amount, memo: memoController.text, isChange: false, @@ -683,11 +718,11 @@ class _DesktopSendState extends ConsumerState { txData: TxData( recipients: [ TxRecipient( - address: _address!, + address: destination, amount: amount, isChange: false, addressType: wallet.cryptoCurrency.getAddressType( - _address!, + destination, )!, ), ], @@ -711,18 +746,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, @@ -740,10 +775,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, @@ -762,13 +797,13 @@ class _DesktopSendState extends ConsumerState { txDataFuture = wallet.prepareSend( txData: TxData( xelisSendAll: coin is Xelis && _xelisSendAll, - 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, @@ -791,12 +826,12 @@ class _DesktopSendState extends ConsumerState { txData = results.first as TxData; - if (wasCancelled || !mounted) { + if (!attempt.isCurrent || !mounted) { await wallet.cancelSend(txData: txData); return; } - if (!wasCancelled && mounted) { + if (mounted) { if (isPaynymSend) { txData = txData.copyWith( paynymAccountLite: widget.accountLite!, @@ -811,8 +846,7 @@ class _DesktopSendState extends ConsumerState { txData = txData.copyWith(noteOnChain: _onChainNote ?? ""); } } - // pop building dialog - Navigator.of(context, rootNavigator: true).pop(); + progress.close(); unawaited( showDialog( @@ -831,75 +865,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; } @@ -1324,16 +1296,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); @@ -1393,8 +1372,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(); @@ -1415,6 +1402,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), ); @@ -1426,7 +1423,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) @@ -1852,16 +1857,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 && @@ -2079,7 +2082,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; @@ -2107,8 +2111,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..20af5fcd98 --- /dev/null +++ b/test/pages/send_view/open_alias_preview_test.dart @@ -0,0 +1,509 @@ +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 = []; + final cancelled = []; + @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)); + } + + @override + Future cancelSend({required TxData txData}) async { + cancelled.add(txData); + } +} + +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'); + // A late prepared transaction is released rather than discarded. + expect(h.wallet.cancelled, hasLength(fail ? 0 : 1)); + 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 e1a906c5b12523e8e9b6a6533cd4e419fa24b713 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 1 Oct 2026 11:27:58 -0500 Subject: [PATCH 08/32] 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 f2859f0ace4f0c2149043540267fba71783b665b Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 6 Oct 2026 21:26:34 -0500 Subject: [PATCH 09/32] fix(send): prevent overflow in phone-sized OpenAlias flow Select mobile and desktop layouts explicitly in widget tests by faking Util.layoutPlatform, restored during teardown. This avoids relying on the host platform and the Linux-only small-screen exception. 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 +++++++++--------- .../send_view/open_alias_preview_test.dart | 26 ++- 2 files changed, 132 insertions(+), 110 deletions(-) diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 55b3128bcc..78459ac36d 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -1668,106 +1668,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, + ), + ], + ), ), - ), - ); - }, + ); + }, + ), ), ], ), @@ -1794,20 +1806,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/test/pages/send_view/open_alias_preview_test.dart b/test/pages/send_view/open_alias_preview_test.dart index 20af5fcd98..305b5a2271 100644 --- a/test/pages/send_view/open_alias_preview_test.dart +++ b/test/pages/send_view/open_alias_preview_test.dart @@ -6,6 +6,7 @@ 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:platform/platform.dart' as platform; import 'package:stackwallet/models/balance.dart'; import 'package:stackwallet/models/isar/models/blockchain_data/address.dart'; import 'package:stackwallet/models/isar/stack_theme.dart'; @@ -34,7 +35,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); @@ -168,12 +171,25 @@ 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 previousPlatform = Util.layoutPlatform; + final previousWidth = Util.screenWidth; + final previousIsIpad = Util.isIpad; + Util.layoutPlatform = platform.FakePlatform( + operatingSystem: desktop ? 'macos' : 'android', + ); + Util.isIpad = false; + tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; - Util.screenWidth = desktop ? 1200 : 600; + Util.screenWidth = size.width; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - addTearDown(() => Util.screenWidth = null); + addTearDown(() { + Util.layoutPlatform = previousPlatform; + Util.screenWidth = previousWidth; + Util.isIpad = previousIsIpad; + }); + expect(Util.isDesktop, desktop); final theme = StackTheme.fromJson(json: lightThemeJsonMap); container = ProviderContainer( overrides: [ @@ -323,7 +339,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 { From 2fc522e27026df220219c4e19356ccddc02f9e32 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 1 Oct 2026 14:32:18 -0500 Subject: [PATCH 10/32] 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 3fc22cd23c97a5b9c8de114c3df36d59f4f0922b Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 1 Oct 2026 14:32:45 -0500 Subject: [PATCH 11/32] 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 c0c18c3e19f745cbc5f695c89282165f09dae34e Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 1 Oct 2026 14:34:02 -0500 Subject: [PATCH 12/32] 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 15a7a5231b0e1abf7a32a07e70b01e57e7b16395 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 1 Oct 2026 14:34:29 -0500 Subject: [PATCH 13/32] 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 98e6f4886132b0c9ff5889ba3b31f48c8b5ca5da Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 1 Oct 2026 14:35:22 -0500 Subject: [PATCH 14/32] 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 aa34affcf5..0bbb2b9852 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -800,7 +800,7 @@ class _ConfirmTransactionViewState ), const SizedBox(height: 4), Text( - widget.txData.openAliasRecipient!.domain, + widget.txData.openAliasRecipient!.displayAlias, style: STextStyles.itemSubtitle12(context), ), ], @@ -1185,7 +1185,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 305b5a2271..b38913cb13 100644 --- a/test/pages/send_view/open_alias_preview_test.dart +++ b/test/pages/send_view/open_alias_preview_test.dart @@ -364,8 +364,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(); @@ -379,7 +380,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 9103c5084cec599787b0f25b063d148ab5249b2d Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 1 Oct 2026 14:36:04 -0500 Subject: [PATCH 15/32] 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 b38913cb13..87332e62b7 100644 --- a/test/pages/send_view/open_alias_preview_test.dart +++ b/test/pages/send_view/open_alias_preview_test.dart @@ -412,7 +412,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(); @@ -458,6 +459,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 e957e53505410595db17d8e0020ced20cb598683 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 1 Oct 2026 14:36:37 -0500 Subject: [PATCH 16/32] 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 90a9afae7e849dbb9e921442517ed04608cb0b3c Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 1 Oct 2026 14:37:08 -0500 Subject: [PATCH 17/32] 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'); }); From c89609befb145c0e909a920d1da30fdc4893750e Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 2 Oct 2026 14:31:09 -0500 Subject: [PATCH 18/32] feat(openalias): resolve aliases with the standalone OpenAlias packages Replace the in-tree DoH JSON client, SOCKS/TLS tunnel and record parser with the openalias, doh_resolver, dnssec_resolver and socks_socket packages. DNSSEC is validated on the device from wire responses, and only evidence from DnssecResolver.validator is trusted, so a resolver's AD bit is no longer enough. Each Tor lookup sends a random SOCKS isolation token, so its queries share a circuit no other traffic uses. Tor lookups keep 10 s to connect and 20 s per request (direct: 5 s and 12 s). A lookup makes several sequential requests, so the whole validation gets 40 s over Tor and 20 s direct. The alias stays as entered for display, the Incognito message still points at Stack Experience, and missing-record errors name the alias, reporting a nonexistent name apart from a failed query. The DoH transport tests and their TLS certificates covered the removed code and are dropped; the remaining tests mock authenticated TXT results. scripts/dev/use_openalias_packages.dart points the generated pubspec at a local package workspace. --- .gitignore | 1 + .../openalias/dns_proxy_connection.dart | 114 ------ lib/services/openalias/dns_proxy_socket.dart | 209 ---------- lib/services/openalias/doh_open_alias.dart | 198 ---------- lib/services/openalias/open_alias.dart | 142 +------ .../openalias/open_alias_service.dart | 163 +++++++- .../templates/pubspec.template.yaml | 4 + scripts/dev/use_openalias_packages.dart | 45 +++ .../send_view/open_alias_preview_test.dart | 14 +- .../openalias/doh_open_alias_test.dart | 165 -------- .../openalias/doh_transport_test.dart | 358 ------------------ .../fixtures/dns_google_test_cert.pem | 19 - .../fixtures/dns_google_test_key.pem | 28 -- .../openalias/open_alias_service_test.dart | 18 +- test/services/openalias/open_alias_test.dart | 61 --- .../openalias/open_alias_test_fixtures.dart | 19 + 16 files changed, 248 insertions(+), 1310 deletions(-) delete mode 100644 lib/services/openalias/dns_proxy_connection.dart delete mode 100644 lib/services/openalias/dns_proxy_socket.dart delete mode 100644 lib/services/openalias/doh_open_alias.dart create mode 100644 scripts/dev/use_openalias_packages.dart delete mode 100644 test/services/openalias/doh_open_alias_test.dart delete mode 100644 test/services/openalias/doh_transport_test.dart delete mode 100644 test/services/openalias/fixtures/dns_google_test_cert.pem delete mode 100644 test/services/openalias/fixtures/dns_google_test_key.pem create mode 100644 test/services/openalias/open_alias_test_fixtures.dart diff --git a/.gitignore b/.gitignore index 1b5b2b46cc..4ac7bfa2a2 100644 --- a/.gitignore +++ b/.gitignore @@ -74,6 +74,7 @@ secp256k1.dll ## other generated project files pubspec.yaml +pubspec_overrides.yaml /android/app/build.gradle /android/app/src/debug/AndroidManifest.xml diff --git a/lib/services/openalias/dns_proxy_connection.dart b/lib/services/openalias/dns_proxy_connection.dart deleted file mode 100644 index 3235cd6d44..0000000000 --- a/lib/services/openalias/dns_proxy_connection.dart +++ /dev/null @@ -1,114 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -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(); - bool _cancelled = false; - Future get socket => _result.future; - - 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, - SecurityContext? context, - ) async { - try { - final raw = await RawSocket.connect( - host, - port, - timeout: proxyConnectTimeout, - ); - _transport = raw; - if (_cancelled) throw const SocketException('DNS connection cancelled'); - final transport = _socket = DnsProxySocket(raw); - final reader = ChunkedStreamReader(transport); - Future> read(int count) async { - final bytes = await reader.readBytes(count); - if (bytes.length != count) { - throw const SocketException('Incomplete SOCKS response'); - } - return bytes; - } - - transport.add([5, 1, 0]); - final greeting = await read(2); - if (greeting[0] != 5 || greeting[1] != 0) { - throw const SocketException('SOCKS authentication failed'); - } - 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'); - } - 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 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 _socket!; - } catch (_) { - _socket?.destroy(); - final transport = _transport; - if (transport != null) unawaited(transport.close()); - rethrow; - } - } - - void cancel() { - if (_cancelled) return; - _cancelled = true; - 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 deleted file mode 100644 index 1b88198aea..0000000000 --- a/lib/services/openalias/dns_proxy_socket.dart +++ /dev/null @@ -1,209 +0,0 @@ -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/lib/services/openalias/doh_open_alias.dart b/lib/services/openalias/doh_open_alias.dart deleted file mode 100644 index a3f65781c0..0000000000 --- a/lib/services/openalias/doh_open_alias.dart +++ /dev/null @@ -1,198 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'dns_proxy_connection.dart'; -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; - - const DohOpenAlias({this.connectTimeout, this.lookupTimeout}); - - Future> lookup( - String domain, { - required ({InternetAddress host, int port})? proxyInfo, - }) async { - final viaTor = proxyInfo != null; - final client = HttpClient() - ..connectionTimeout = - connectTimeout ?? (viaTor ? torConnectTimeout : directConnectTimeout); - 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( - lookupTimeout ?? (viaTor ? torLookupTimeout : directLookupTimeout), - ); - } 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.'); - } - try { - return normalizeOpenAlias(value); - } on OpenAliasException { - throw const OpenAliasException('Invalid DNS response.'); - } -} - -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 OpenAliasException( - 'The DNS lookup for $domain failed. 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 index 9b84f9c81a..9654a05956 100644 --- a/lib/services/openalias/open_alias.dart +++ b/lib/services/openalias/open_alias.dart @@ -1,12 +1,15 @@ -class OpenAliasRecipient { - final String domain; - final String address; +import 'package:openalias/openalias.dart' as oa; + +class OpenAliasRecipient extends oa.OpenAliasRecipient { // The alias as the user entered it, e.g. user@example.com. final String displayAlias; const OpenAliasRecipient({ - required this.domain, - required this.address, + required super.domain, + required super.address, + super.application, + super.record, + super.dns, String? displayAlias, }) : displayAlias = displayAlias ?? domain; } @@ -24,130 +27,9 @@ class OpenAliasException implements Exception { } 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); - // 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) || - 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 OpenAliasException( - 'No Monero OpenAlias record was found for $domain.', - ); - } - if (addresses.length != 1) { - throw const OpenAliasException( - 'This alias has multiple Monero addresses. ' - 'Ask the recipient for an address.', - ); + try { + return oa.normalizeOpenAlias(input); + } on oa.OpenAliasException catch (error) { + throw OpenAliasException(error.message); } - 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 index 318a49394e..eaefe14273 100644 --- a/lib/services/openalias/open_alias_service.dart +++ b/lib/services/openalias/open_alias_service.dart @@ -1,31 +1,89 @@ +import 'dart:io'; +import 'dart:math'; + +import 'package:dnssec_resolver/dnssec_resolver.dart'; +import 'package:doh_resolver/doh_resolver_io.dart'; +import 'package:openalias/openalias.dart' as oa; + import '../../app_config.dart'; import '../../utilities/prefs.dart'; import '../tor_service.dart'; -import 'doh_open_alias.dart'; import 'open_alias.dart'; class OpenAliasService { + static const directConnectTimeout = Duration(seconds: 5); + static const directRequestTimeout = Duration(seconds: 12); + static const directLookupTimeout = Duration(seconds: 20); + // Over Tor each connection also waits for the exit stream and TLS via Tor. + static const torConnectTimeout = Duration(seconds: 10); + static const torRequestTimeout = Duration(seconds: 20); + static const torLookupTimeout = Duration(seconds: 40); + final bool Function() _externalCalls; final bool Function() _useTor; final bool Function() _supportsTor; - final Future> Function(String, bool) _lookup; + final List _trustedValidators; + final ({InternetAddress host, int port}) Function() _torProxy; + late final Future Function(String, bool) _lookup; OpenAliasService({ bool Function()? externalCalls, bool Function()? useTor, bool Function()? supportsTor, - Future> Function(String, bool)? lookup, + Future Function(String, bool)? lookup, + List? trustedValidators, + ({InternetAddress host, int port}) Function()? torProxy, }) : _externalCalls = externalCalls ?? (() => Prefs.instance.externalCalls), _useTor = useTor ?? (() => Prefs.instance.useTor), _supportsTor = supportsTor ?? (() => AppConfig.hasFeature(AppFeature.tor)), - _lookup = lookup ?? _lookupDns; + _trustedValidators = trustedValidators ?? [DnssecResolver.validator], + _torProxy = + torProxy ?? (() => TorService.sharedInstance.getProxyInfo()) { + _lookup = lookup ?? _lookupDns; + } - static Future> _lookupDns(String domain, bool useTor) => - const DohOpenAlias().lookup( - domain, - proxyInfo: useTor ? TorService.sharedInstance.getProxyInfo() : null, - ); + Future _lookupDns(String domain, bool useTor) async { + final proxy = useTor ? _torProxy() : null; + final transport = IoDohTransport( + proxy: proxy == null + ? null + : SocksProxy( + proxy.host, + proxy.port, + // A fresh Tor circuit for each lookup. + credentials: SocksCredentials.isolation(_isolationToken()), + ), + proxyDestination: proxy == null ? null : InternetAddress('8.8.8.8'), + connectionTimeout: useTor ? torConnectTimeout : directConnectTimeout, + requestTimeout: useTor ? torRequestTimeout : directRequestTimeout, + ); + try { + return await DnssecResolver( + timeout: useTor ? torLookupTimeout : directLookupTimeout, + transport: OpenAliasDnsTransport(transport, () { + if (!_externalCalls() || _useTor() != useTor) return false; + if (useTor) { + if (!_supportsTor()) return false; + final current = _torProxy(); + return current.host.address == proxy!.host.address && + current.port == proxy.port; + } + return true; + }), + ).lookupTxt(domain); + } finally { + await transport.close(); + } + } + + static String _isolationToken() { + final random = Random.secure(); + return List.generate( + 16, + (_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'), + ).join(); + } Future resolve( String input, { @@ -44,20 +102,65 @@ class OpenAliasService { 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, + final recipient = await oa.OpenAliasResolver( + application: 'xmr', + requiredAuthentication: DnsAuthentication.locallyValidated, + trustedValidators: _trustedValidators, validateAddress: validateAddress, + lookup: (name) async { + final result = await _lookup(name, useTor); + if (_useTor() != useTor || !_externalCalls()) { + throw const OpenAliasException( + 'Privacy settings changed. Please look up the alias again.', + ); + } + return result; + }, + ).resolve(domain); + return OpenAliasRecipient( + domain: recipient.domain, + address: recipient.address, + application: recipient.application, + record: recipient.record, + dns: recipient.dns, ); } on OpenAliasException { rethrow; + } on oa.OpenAliasException catch (error) { + throw OpenAliasException(switch (error.code) { + oa.OpenAliasError.authenticationUnavailable => + 'Local DNSSEC verification failed. ' + 'Ask the recipient for a verified alias or address.', + oa.OpenAliasError.noRecord => + 'No Monero OpenAlias record was found for $domain.', + oa.OpenAliasError.invalidAddress => + 'This alias contains an invalid Monero address for this wallet.', + oa.OpenAliasError.ambiguous => + 'This alias has multiple Monero addresses. ' + 'Ask the recipient for an address.', + oa.OpenAliasError.questionMismatch => + 'The DNS response does not match this alias.', + _ => error.message, + }); + } on DnsException catch (error) { + throw OpenAliasException(switch (error.code) { + DnsError.authenticationUnavailable => + 'DNSSEC verification failed or is unavailable. ' + 'Ask the recipient for a verified alias or address.', + DnsError.noRecords => 'No OpenAlias record exists for $domain.', + DnsError.queryFailed => + 'The DNS lookup for $domain failed. No address was accepted.', + DnsError.questionMismatch => + 'The DNS response does not match this alias.', + DnsError.aliasLoop => 'The alias has a DNS loop.', + DnsError.aliasAmbiguous => 'The DNS alias is ambiguous.', + DnsError.aliasTooLong => 'The DNS alias chain is too long.', + DnsError.responseTooLarge => 'The DNS response is too large.', + DnsError.malformedResponse || + DnsError.invalidName => 'Invalid DNS response.', + DnsError.transport || DnsError.cancelled || DnsError.timedOut => + 'OpenAlias lookup failed. Check your connection and try again.', + }); } catch (_) { throw const OpenAliasException( 'OpenAlias lookup is unavailable. ' @@ -66,3 +169,25 @@ class OpenAliasService { } } } + +class OpenAliasDnsTransport implements DohWireTransport { + final DohWireTransport transport; + final bool Function() allowed; + OpenAliasDnsTransport(this.transport, this.allowed); + + @override + DnsOperation startWire( + Uri uri, + List query, { + required int maxResponseBytes, + }) { + // Key lookups need current permission, just like the initial TXT query. + if (!allowed()) { + throw const DnsException( + DnsError.cancelled, + 'DNS privacy settings changed', + ); + } + return transport.startWire(uri, query, maxResponseBytes: maxResponseBytes); + } +} diff --git a/scripts/app_config/templates/pubspec.template.yaml b/scripts/app_config/templates/pubspec.template.yaml index 28335c42b0..3d7d56636f 100644 --- a/scripts/app_config/templates/pubspec.template.yaml +++ b/scripts/app_config/templates/pubspec.template.yaml @@ -121,6 +121,10 @@ dependencies: # Utility plugins http: ^1.6.0 + openalias: ^0.2.0 + doh_resolver: ^0.3.0 + dnssec_resolver: ^0.1.0 + socks_socket: ^1.4.0 local_auth: ^2.3.0 permission_handler: ^13.0.1 flutter_local_notifications: ^22.3.0 diff --git a/scripts/dev/use_openalias_packages.dart b/scripts/dev/use_openalias_packages.dart new file mode 100644 index 0000000000..e46973ac5d --- /dev/null +++ b/scripts/dev/use_openalias_packages.dart @@ -0,0 +1,45 @@ +import 'dart:convert'; +import 'dart:io'; + +void main(List args) { + if (args.length != 1 || !File('pubspec.yaml').existsSync()) { + throw ArgumentError( + 'Run from the configured app root with the package workspace path.', + ); + } + final workspace = Directory(args.single).absolute; + const packages = [ + 'socks_socket', + 'doh_resolver', + 'dnssec_resolver', + 'openalias', + ]; + for (final package in packages) { + if (!File('${workspace.path}/$package/pubspec.yaml').existsSync()) { + throw ArgumentError('Missing package $package in ${workspace.path}'); + } + } + + // Pub replaces the whole override map; retain the app's overrides. + final lines = File('pubspec.yaml').readAsLinesSync(); + final start = lines.indexOf('dependency_overrides:'); + if (start < 0) { + throw StateError('Expected generated app dependency overrides'); + } + final overrides = []; + for (final line in lines.skip(start + 1)) { + if (line.isNotEmpty && !line.startsWith(' ') && !line.startsWith('#')) { + break; + } + overrides.add(line); + } + final localOverrides = packages.map((package) { + final path = jsonEncode('${workspace.path}/$package'); + return ' $package:\n path: $path\n'; + }).join(); + File('pubspec_overrides.yaml').writeAsStringSync( + 'dependency_overrides:\n${overrides.join('\n')}\n' + '$localOverrides', + ); + stdout.writeln('Wrote local package overrides; run flutter pub get next.'); +} diff --git a/test/pages/send_view/open_alias_preview_test.dart b/test/pages/send_view/open_alias_preview_test.dart index 87332e62b7..54e89bebe6 100644 --- a/test/pages/send_view/open_alias_preview_test.dart +++ b/test/pages/send_view/open_alias_preview_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:doh_resolver/doh_resolver.dart'; import 'package:flutter/material.dart'; import 'package:stackwallet/models/send_view_auto_fill_data.dart'; import 'package:stackwallet/services/openalias/open_alias.dart'; @@ -34,6 +35,7 @@ import 'package:stackwallet/wallets/wallet/intermediate/cryptonote_wallet.dart'; import 'package:stackwallet/wallets/wallet/wallet.dart'; import '../../sample_data/theme_json.dart'; +import '../../services/openalias/open_alias_test_fixtures.dart'; const literal = '4AeRgkWZsMJhAWKMeCZ3h4ZSPnAcW5VBtRFyLd6gBEf6GgJU2FH' @@ -163,7 +165,7 @@ class _Harness { final identity = ValueNotifier('wallet'); final visible = ValueNotifier(true); final auth = _AuthObserver(); - final lookups = >>[]; + final lookups = >[]; int get calls => lookups.length; _Harness(this.desktop); @@ -209,11 +211,12 @@ class _Harness { OpenAliasService( externalCalls: () => true, useTor: () => false, + trustedValidators: testValidators, lookup: (domain, _) { if (domain != 'alice.example') { throw StateError('Unexpected lookup: $domain'); } - final pending = Completer>(); + final pending = Completer(); lookups.add(pending); return pending.future; }, @@ -333,8 +336,11 @@ class _Harness { expect(tester.takeException(), isNull); } - void succeed([String destination = literal]) => - lookups.last.complete(['oa1:xmr recipient_address=$destination;']); + void succeed([String destination = literal]) => lookups.last.complete( + authenticatedTxt('alice.example', [ + 'oa1:xmr recipient_address=$destination;', + ]), + ); } void main() { diff --git a/test/services/openalias/doh_open_alias_test.dart b/test/services/openalias/doh_open_alias_test.dart deleted file mode 100644 index f229a827f9..0000000000 --- a/test/services/openalias/doh_open_alias_test.dart +++ /dev/null @@ -1,165 +0,0 @@ -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('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('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( - 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 [ - [ - {'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 deleted file mode 100644 index 32d23f0dbf..0000000000 --- a/test/services/openalias/doh_transport_test.dart +++ /dev/null @@ -1,358 +0,0 @@ -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( - const DohOpenAlias(lookupTimeout: Duration(seconds: 1)).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: 4)); - 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(); - 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( - const 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(); - } - }, - ); -} - -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 deleted file mode 100644 index d000e0196b..0000000000 --- a/test/services/openalias/fixtures/dns_google_test_cert.pem +++ /dev/null @@ -1,19 +0,0 @@ ------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 deleted file mode 100644 index 5204031813..0000000000 --- a/test/services/openalias/fixtures/dns_google_test_key.pem +++ /dev/null @@ -1,28 +0,0 @@ ------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----- diff --git a/test/services/openalias/open_alias_service_test.dart b/test/services/openalias/open_alias_service_test.dart index 03232b0313..d52c44662f 100644 --- a/test/services/openalias/open_alias_service_test.dart +++ b/test/services/openalias/open_alias_service_test.dart @@ -1,9 +1,12 @@ import 'dart:async'; +import 'package:doh_resolver/doh_resolver.dart'; 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 'open_alias_test_fixtures.dart'; + void main() { test('privacy restrictions prevent any network lookup', () async { var calls = 0; @@ -14,7 +17,7 @@ void main() { supportsTor: () => policy.$3, lookup: (_, _) async { calls++; - return []; + return authenticatedTxt('alice.example', []); }, ); await expectLater( @@ -27,7 +30,7 @@ void main() { test('Incognito mode points to the setting that allows lookups', () async { final service = OpenAliasService( externalCalls: () => false, - lookup: (_, _) async => [], + lookup: (_, _) async => authenticatedTxt('alice.example', []), ); await expectLater( service.resolve('alice.example', validateAddress: (_) => true), @@ -44,7 +47,7 @@ void main() { 'Tor policy is passed to the lookup and a changed policy rejects results', () async { var tor = true; - final pending = Completer>(); + final pending = Completer(); final service = OpenAliasService( externalCalls: () => true, useTor: () => tor, @@ -60,7 +63,9 @@ void main() { validateAddress: (_) => true, ); tor = false; - pending.complete(['oa1:xmr recipient_address=valid;']); + pending.complete( + authenticatedTxt('alice.example', ['oa1:xmr recipient_address=valid;']), + ); await expectLater(future, throwsA(isA())); }, ); @@ -69,7 +74,10 @@ void main() { externalCalls: () => true, useTor: () => false, supportsTor: () => true, - lookup: (_, _) async => ['oa1:xmr recipient_address=valid;'], + lookup: (_, _) async => authenticatedTxt('alice.example', [ + 'oa1:xmr recipient_address=valid;', + ]), + trustedValidators: testValidators, ); final result = await service.resolve( 'alice.example', diff --git a/test/services/openalias/open_alias_test.dart b/test/services/openalias/open_alias_test.dart index 4d0a22d030..0d1c353245 100644 --- a/test/services/openalias/open_alias_test.dart +++ b/test/services/openalias/open_alias_test.dart @@ -1,12 +1,6 @@ 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'); @@ -16,18 +10,6 @@ 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'); }); @@ -51,47 +33,4 @@ void main() { ); }); } - 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_test_fixtures.dart b/test/services/openalias/open_alias_test_fixtures.dart new file mode 100644 index 0000000000..ed6c383cd3 --- /dev/null +++ b/test/services/openalias/open_alias_test_fixtures.dart @@ -0,0 +1,19 @@ +import 'dart:convert'; + +import 'package:doh_resolver/doh_resolver.dart'; + +final _issuer = DnsValidationIssuer('stack-test'); + +final testValidators = [_issuer.validator]; + +AuthenticatedTxtResult authenticatedTxt(String domain, List records) => + _issuer.issueTxt( + question: domain, + canonicalName: domain, + records: records + .map( + (s) => DnsTxtRecord(name: domain, ttl: 60, bytes: utf8.encode(s)), + ) + .toList(), + resolver: Uri.parse('https://resolver.example/dns-query'), + ); From 0be1d068fce8d8ce5ec8e5feb145c73c73482fdd Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 2 Oct 2026 14:31:36 -0500 Subject: [PATCH 19/32] test(openalias): cover the package-based lookup Cover the trusted-validator policy, per-lookup Tor isolation, privacy revocation between DNSSEC queries, wallet rejection rules, embedded quotes in recipient names, and the alias named in not-found and failed-query errors. A Flutter test loads the native validator, and the opt-in live tests validate DNSSEC on the device and check the not-found message for dan@cypherstack. --- .../openalias/dnssec_native_test.dart | 35 ++ .../openalias/open_alias_live_test.dart | 25 ++ .../openalias/open_alias_packages_test.dart | 319 ++++++++++++++++++ test/services/openalias/open_alias_test.dart | 3 + 4 files changed, 382 insertions(+) create mode 100644 test/services/openalias/dnssec_native_test.dart create mode 100644 test/services/openalias/open_alias_packages_test.dart diff --git a/test/services/openalias/dnssec_native_test.dart b/test/services/openalias/dnssec_native_test.dart new file mode 100644 index 0000000000..ef76e95ccc --- /dev/null +++ b/test/services/openalias/dnssec_native_test.dart @@ -0,0 +1,35 @@ +import 'package:dnssec_resolver/dnssec_resolver.dart'; +import 'package:doh_resolver/doh_resolver.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test( + 'Flutter loads the native validator and dispatches its DNS query', + () async { + final transport = _Transport(); + await expectLater( + DnssecResolver(transport: transport).lookupTxt('alice.example'), + throwsA( + isA().having((e) => e.code, 'code', DnsError.transport), + ), + ); + expect(transport.calls, 1); + }, + ); +} + +class _Transport implements DohWireTransport { + int calls = 0; + @override + DnsOperation startWire( + Uri uri, + List query, { + required int maxResponseBytes, + }) { + calls++; + throw const DnsException( + DnsError.transport, + 'Expected test transport failure', + ); + } +} diff --git a/test/services/openalias/open_alias_live_test.dart b/test/services/openalias/open_alias_live_test.dart index 54bc3f2fe5..224333d4f5 100644 --- a/test/services/openalias/open_alias_live_test.dart +++ b/test/services/openalias/open_alias_live_test.dart @@ -1,5 +1,7 @@ import 'dart:io'; +import 'package:dnssec_resolver/dnssec_resolver.dart'; +import 'package:doh_resolver/doh_resolver_io.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:stackwallet/services/openalias/open_alias.dart'; import 'package:stackwallet/services/openalias/open_alias_service.dart'; @@ -12,6 +14,17 @@ void main() { useTor: () => false, ); final validate = Monero(CryptoCurrencyNetwork.main).validateAddress; + test('native DNSSEC and Monero address validation run in Flutter', () async { + final dns = await DnssecResolver(transport: IoDohTransport()) + .lookupTxt('donate.getmonero.org'); + expect(dns.authentication, DnsAuthentication.locallyValidated); + final record = dns.records + .map((r) => r.text) + .firstWhere((r) => r.startsWith('oa1:xmr ')); + final address = RegExp(r'recipient_address=([^;]+)') + .firstMatch(record)![1]!; + expect(validate(address), isTrue); + }, skip: skip); test( 'live signed alias resolves to a valid Monero mainnet address', () async { @@ -24,6 +37,18 @@ void main() { }, skip: skip, ); + test('live nonexistent name is reported with the alias', () async { + await expectLater( + service.resolve('dan@cypherstack', validateAddress: validate), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'No OpenAlias record exists for dan.cypherstack.', + ), + ), + ); + }, skip: skip); for (final domain in ['openalias.org', 'dnssec-failed.org']) { test('live unsigned or bogus DNS is rejected: $domain', () async { await expectLater( diff --git a/test/services/openalias/open_alias_packages_test.dart b/test/services/openalias/open_alias_packages_test.dart new file mode 100644 index 0000000000..c33305eb73 --- /dev/null +++ b/test/services/openalias/open_alias_packages_test.dart @@ -0,0 +1,319 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:doh_resolver/doh_resolver.dart'; +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 'open_alias_test_fixtures.dart'; + +void main() { + test('privacy revocation blocks subsequent DNSSEC key requests', () async { + var allowed = true; + final underlying = _CountingTransport(); + final transport = OpenAliasDnsTransport(underlying, () => allowed); + final endpoint = Uri.https('resolver.example', '/dns-query'); + await transport + .startWire(endpoint, List.filled(12, 0), maxResponseBytes: 65535) + .result; + allowed = false; + expect( + () => transport.startWire( + endpoint, + List.filled(12, 0), + maxResponseBytes: 65535, + ), + throwsA( + isA().having((e) => e.code, 'code', DnsError.cancelled), + ), + ); + expect(underlying.calls, 1); + }); + test( + 'remote AD assertion cannot satisfy the wallet local-validation policy', + () async { + final remote = DnsResponse.fromJson( + { + 'Status': 0, + 'AD': true, + 'CD': false, + 'TC': false, + 'Question': [ + {'name': 'alice.example', 'type': 16}, + ], + 'Answer': [ + { + 'name': 'alice.example', + 'type': 16, + 'TTL': 60, + 'data': 'oa1:xmr recipient_address=valid;', + }, + ], + }, + resolver: Uri.https('resolver.example', '/resolve'), + ).authenticatedTxt('alice.example'); + final resolver = OpenAliasService( + externalCalls: () => true, + useTor: () => false, + lookup: (_, _) async => remote, + ); + await expectLater( + resolver.resolve('alice.example', validateAddress: (_) => true), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Local DNSSEC'), + ), + ), + ); + }, + ); + OpenAliasService service(List records) => OpenAliasService( + externalCalls: () => true, + useTor: () => false, + lookup: (name, _) async => authenticatedTxt(name, records), + trustedValidators: testValidators, + ); + for (final records in [ + ['oa1:btc recipient_address=valid;'], + ['oa1:xmr recipient_address=wrong-network;'], + ['oa1:xmr recipient_address=valid;tx_payment_id=123;'], + ['oa1:xmr recipient_address=valid;', 'oa1:xmr recipient_address=second;'], + ['oa1:xmr recipient_address=valid;recipient_address=second;'], + ]) { + test( + 'package migration retains wallet rejection policy: $records', + () async { + await expectLater( + service(records).resolve( + 'alice.example', + validateAddress: (s) => s == 'valid' || s == 'second', + ), + throwsA(isA()), + ); + }, + ); + } + test('payment ID aliases keep the wallet message', () async { + await expectLater( + service(['oa1:xmr recipient_address=valid;tx_payment_id=123;']) + .resolve('alice.example', validateAddress: (_) => true), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('separate payment ID'), + ), + ), + ); + }); + test('only the on-device validator is trusted by default', () async { + final resolver = OpenAliasService( + externalCalls: () => true, + useTor: () => false, + lookup: (name, _) async => + authenticatedTxt(name, ['oa1:xmr recipient_address=valid;']), + ); + await expectLater( + resolver.resolve('alice.example', validateAddress: (_) => true), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Local DNSSEC'), + ), + ), + ); + }); + test('DNS metadata does not replace the literal recipient', () async { + final result = await service([ + 'oa1:xmr recipient_address=valid;tx_amount=999;recipient_name=Someone;', + ]).resolve('Alice@Example.', validateAddress: (s) => s == 'valid'); + expect(result.address, 'valid'); + expect(result.domain, 'alice.example'); + expect(result.dns!.authentication, DnsAuthentication.locallyValidated); + expect(result.displayAlias, 'alice.example'); + }); + test('quotes inside an unquoted recipient name are literal', () async { + final result = await service([ + 'oa1:xmr recipient_address=valid; recipient_name=Alice "Vendor" Smith;', + ]).resolve('alice.example', validateAddress: (s) => s == 'valid'); + expect(result.address, 'valid'); + expect(result.record!.fields['recipient_name'], 'Alice "Vendor" Smith'); + }); + test( + 'parses quoted values with semicolons and skips other prefixes', + () async { + final result = await service([ + 'oa1:btc recipient_address=bad;', + 'oa1:xmrfoo recipient_address=bad;', + 'oa1:xmr recipient_name="Alice; Example";recipient_address="valid";', + ]).resolve('alice.example', validateAddress: (s) => s == 'valid'); + expect(result.address, 'valid'); + expect(result.record!.fields['recipient_name'], 'Alice; Example'); + }, + ); + test('missing records name the alias', () async { + await expectLater( + service(['oa1:btc recipient_address=valid;']) + .resolve('Alice@Example', validateAddress: (_) => true), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'No Monero OpenAlias record was found for alice.example.', + ), + ), + ); + }); + test('a nonexistent name is reported apart from a failed query', () async { + for (final (code, message) in [ + (DnsError.noRecords, 'No OpenAlias record exists for alice.example.'), + ( + DnsError.queryFailed, + 'The DNS lookup for alice.example failed. No address was accepted.', + ), + ]) { + final resolver = OpenAliasService( + externalCalls: () => true, + useTor: () => false, + lookup: (_, _) async => throw DnsException(code, 'failed'), + ); + await expectLater( + resolver.resolve('alice@example', validateAddress: (_) => true), + throwsA( + isA().having( + (e) => e.message, + 'message', + message, + ), + ), + ); + } + }); + test('revoked external calls reject an in-flight result', () async { + var allowed = true; + final pending = Completer(); + final resolver = OpenAliasService( + externalCalls: () => allowed, + useTor: () => false, + lookup: (_, _) => pending.future, + ); + final future = resolver.resolve( + 'alice.example', + validateAddress: (_) => true, + ); + allowed = false; + pending.complete( + authenticatedTxt('alice.example', ['oa1:xmr recipient_address=valid;']), + ); + await expectLater( + future, + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Privacy settings changed'), + ), + ), + ); + }); + test( + 'package authentication failure is translated into a visible UI failure', + () async { + final resolver = OpenAliasService( + externalCalls: () => true, + useTor: () => false, + lookup: (_, _) async => throw const DnsException( + DnsError.authenticationUnavailable, + 'untrusted', + ), + ); + await expectLater( + resolver.resolve('alice.example', validateAddress: (_) => true), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('DNSSEC'), + ), + ), + ); + }, + ); + test('each Tor lookup uses its own SOCKS isolation credentials', () async { + final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(server.close); + final usernames = []; + server.listen((socket) { + final buffer = []; + var greeted = false; + socket.listen((data) { + buffer.addAll(data); + if (!greeted) { + if (buffer.length < 2 || buffer.length < 2 + buffer[1]) return; + final methods = buffer.sublist(2, 2 + buffer[1]); + buffer.removeRange(0, 2 + buffer[1]); + greeted = true; + if (!methods.contains(2)) { + socket.destroy(); + return; + } + socket.add([5, 2]); + } + if (buffer.length < 2 || buffer.length < 3 + buffer[1]) return; + final userLength = buffer[1]; + if (buffer.length < 3 + userLength + buffer[2 + userLength]) return; + usernames.add(String.fromCharCodes(buffer.sublist(2, 2 + userLength))); + socket.add([1, 1]); + socket.destroy(); + }, onError: (_) {}); + }); + final resolver = OpenAliasService( + externalCalls: () => true, + useTor: () => true, + supportsTor: () => true, + torProxy: () => (host: InternetAddress.loopbackIPv4, port: server.port), + ); + for (var i = 1; i <= 2; i++) { + await expectLater( + resolver.resolve('alice.example', validateAddress: (_) => true), + throwsA(isA()), + ); + expect(usernames.toSet(), hasLength(i)); + } + }); + test('unavailable Tor produces no direct lookup fallback', () async { + var calls = 0; + final resolver = OpenAliasService( + externalCalls: () => true, + useTor: () => true, + supportsTor: () => true, + lookup: (_, tor) async { + calls++; + expect(tor, isTrue); + throw StateError('Tor unavailable'); + }, + ); + await expectLater( + resolver.resolve('alice.example', validateAddress: (_) => true), + throwsA(isA()), + ); + expect(calls, 1); + }); +} + +class _CountingTransport implements DohWireTransport { + int calls = 0; + @override + DnsOperation startWire( + Uri uri, + List query, { + required int maxResponseBytes, + }) { + calls++; + return DnsOperation(Future.value(DohHttpResponse(200, [])), () {}); + } +} diff --git a/test/services/openalias/open_alias_test.dart b/test/services/openalias/open_alias_test.dart index 0d1c353245..6b39ba36a3 100644 --- a/test/services/openalias/open_alias_test.dart +++ b/test/services/openalias/open_alias_test.dart @@ -12,6 +12,8 @@ void main() { }); test('displays aliases as entered, without case or root dot', () { expect(displayOpenAlias(' Dan@CypherStack.com. '), 'dan@cypherstack.com'); + const recipient = OpenAliasRecipient(domain: 'a.example', address: 'x'); + expect(recipient.displayAlias, 'a.example'); }); for (final name in [ 'example', @@ -24,6 +26,7 @@ void main() { 'K.example', '127.0.0.1', 'a.onion', + 'a.localhost', 'a.org\u0000', ]) { test('rejects invalid alias $name', () { From a3346d2d3b94fbd24a86be4f86bab98238f5589e Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 7 Oct 2026 11:43:43 -0500 Subject: [PATCH 20/32] fix(tor): await requests and complete onion node responses --- .../test_monero_node_connection.dart | 73 +++++++++------ .../test_monero_node_connection_test.dart | 88 +++++++++++++++++++ 2 files changed, 132 insertions(+), 29 deletions(-) create mode 100644 test/utilities/test_monero_node_connection_test.dart diff --git a/lib/utilities/test_monero_node_connection.dart b/lib/utilities/test_monero_node_connection.dart index 6a4fbfa455..b8d07afbd4 100644 --- a/lib/utilities/test_monero_node_connection.dart +++ b/lib/utilities/test_monero_node_connection.dart @@ -16,7 +16,7 @@ import 'package:flutter/material.dart'; import 'package:http/io_client.dart'; import 'package:monero_rpc/monero_rpc.dart'; import 'package:socks5_proxy/socks.dart'; -import 'package:socks_socket/socks_socket.dart'; +import 'package:socks_socket/socks.dart'; import '../widgets/desktop/primary_button.dart'; import '../widgets/desktop/secondary_button.dart'; @@ -30,12 +30,7 @@ class MoneroNodeConnectionResponse { final int? port; final bool success; - MoneroNodeConnectionResponse( - this.cert, - this.url, - this.port, - this.success, - ); + MoneroNodeConnectionResponse(this.cert, this.url, this.port, this.success); } Future testMoneroNodeConnection( @@ -43,10 +38,7 @@ Future testMoneroNodeConnection( String? username, String? password, bool allowBadX509Certificate, { - required ({ - InternetAddress host, - int port, - })? proxyInfo, + required ({InternetAddress host, int port})? proxyInfo, }) async { if (uri.host.endsWith(".onion")) { if (proxyInfo == null) { @@ -77,22 +69,27 @@ Future testMoneroNodeConnection( if (line.contains('WWW-authenticate: ')) { // both the password and username needs to be if (username == null || password == null) { - // node asking us for authentication, but we don't have any crendentials. + // The node requires authentication, but credentials are missing. return MoneroNodeConnectionResponse(null, null, null, false); } - authenticateHeaderValue = - line.replaceFirst('WWW-authenticate: ', '').trim(); + authenticateHeaderValue = line + .replaceFirst('WWW-authenticate: ', '') + .trim(); } } - // header to authenticate was present, we need to remake the request with digest + // Retry with digest authentication when the server requests it. if (authenticateHeaderValue != null) { final digestAuth = DigestAuth(username!, password!); digestAuth.initFromAuthorizationHeader(authenticateHeaderValue); // generate the Authorization header for the second request. final authHeader = digestAuth.getAuthString('POST', uri.path); - final rawRequestAuthenticated = - DaemonRpc.rawRequestRpc(uri, 'get_info', {}, authHeader); + final rawRequestAuthenticated = DaemonRpc.rawRequestRpc( + uri, + 'get_info', + {}, + authHeader, + ); // resend with an authenticated request response = await socket.send(rawRequestAuthenticated); } @@ -103,7 +100,7 @@ Future testMoneroNodeConnection( return MoneroNodeConnectionResponse(null, null, null, success); } catch (e, s) { - Logging.instance.w("$e\n$s", error: e, stackTrace: s,); + Logging.instance.w("$e\n$s", error: e, stackTrace: s); return MoneroNodeConnectionResponse(null, null, null, false); } finally { await socket?.close(); @@ -114,10 +111,7 @@ Future testMoneroNodeConnection( try { if (proxyInfo != null) { SocksTCPClient.assignToHttpClient(httpClient, [ - ProxySettings( - proxyInfo.host, - proxyInfo.port, - ), + ProxySettings(proxyInfo.host, proxyInfo.port), ]); } @@ -127,8 +121,12 @@ Future testMoneroNodeConnection( } if (badCertResponse == null) { - badCertResponse = - MoneroNodeConnectionResponse(cert, url, port, false); + badCertResponse = MoneroNodeConnectionResponse( + cert, + url, + port, + false, + ); } else { return false; } @@ -150,7 +148,7 @@ Future testMoneroNodeConnection( if (badCertResponse != null) { return badCertResponse!; } else { - Logging.instance.w("$e\n$s", error: e, stackTrace: s,); + Logging.instance.w("$e\n$s", error: e, stackTrace: s); return MoneroNodeConnectionResponse(null, null, null, false); } } finally { @@ -204,14 +202,31 @@ Future showBadX509CertificateDialog( extension on SOCKSSocket { /// write the raw request to the socket and return the response as String Future send(String rawRequest) async { - write(rawRequest); + await write(rawRequest); final buffer = StringBuffer(); - await for (final response in inputStream) { - buffer.write(utf8.decode(response)); - if (buffer.toString().contains("\r\n\r\n")) { + await for (final response in inputStream.transform(utf8.decoder)) { + buffer.write(response); + if (_isComplete(buffer.toString())) { break; } } return buffer.toString(); } } + +/// Whether [response] has its headers and, if it declares a Content-Length, +/// its whole body. Over Tor the body often arrives after the headers. +bool _isComplete(String response) { + final headerEnd = response.indexOf("\r\n\r\n"); + if (headerEnd < 0) { + return false; + } + final length = RegExp( + r"^content-length:\s*(\d+)", + caseSensitive: false, + multiLine: true, + ).firstMatch(response.substring(0, headerEnd))?.group(1); + return length == null || + utf8.encode(response.substring(headerEnd + 4)).length >= + int.parse(length); +} diff --git a/test/utilities/test_monero_node_connection_test.dart b/test/utilities/test_monero_node_connection_test.dart new file mode 100644 index 0000000000..e4f93086f7 --- /dev/null +++ b/test/utilities/test_monero_node_connection_test.dart @@ -0,0 +1,88 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/utilities/test_monero_node_connection.dart'; + +/// A SOCKS5 proxy that hands each tunnel to [onTunnel] after the CONNECT reply. +Future _proxy( + Future Function(Socket peer, Stream> request) onTunnel, +) async { + final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(server.close); + server.listen((peer) { + addTearDown(peer.destroy); + final buffer = []; + final request = StreamController>(); + var tunnel = false; + peer.listen( + (bytes) { + if (tunnel) { + request.add(bytes); + return; + } + buffer.addAll(bytes); + if (buffer.length == 3) { + peer.add([5, 0]); + } else if (buffer.length > 8 && buffer.length == 10 + buffer[7]) { + tunnel = true; + peer.add([5, 0, 0, 1, 0, 0, 0, 0, 0, 0]); + unawaited(onTunnel(peer, request.stream)); + } + }, + onError: (Object _) {}, + onDone: request.close, + ); + }); + return server; +} + +Future _check(ServerSocket proxy) => + testMoneroNodeConnection( + Uri.parse("http://node.onion:18081/json_rpc"), + null, + null, + false, + proxyInfo: (host: InternetAddress.loopbackIPv4, port: proxy.port), + ); + +void main() { + test("onion check reads a body sent after the headers", () async { + const body = '{"jsonrpc":"2.0","id":"0","result":{"status":"OK"}}'; + final proxy = await _proxy((peer, request) async { + await request.first; + peer.add( + utf8.encode( + "HTTP/1.1 200 Ok\r\n" + "Content-Type: application/json\r\n" + "Content-Length: ${body.length}\r\n\r\n", + ), + ); + await peer.flush(); + await Future.delayed(const Duration(milliseconds: 50)); + peer.add(utf8.encode(body)); + await peer.flush(); + }); + + expect((await _check(proxy)).success, isTrue); + }); + + test("onion check reports a reset connection as a failure", () async { + final proxy = await _proxy((peer, request) async { + await peer.flush(); + // Linux SO_LINGER {on=1, seconds=0} requests a TCP reset on close. + peer.setRawOption( + RawSocketOption( + 1, + 13, + Uint8List.view(Int32List.fromList([1, 0]).buffer), + ), + ); + peer.destroy(); + }); + + expect((await _check(proxy)).success, isFalse); + }, testOn: "linux"); +} From eb469e92327b9579fb4152cceef00e436e1c0911 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 7 Oct 2026 11:43:44 -0500 Subject: [PATCH 21/32] fix(tor): await native shutdown before reporting disconnected --- test/services/tor_shutdown_test.dart | 48 +++++++++++++++++++ .../TOR_tor_service_impl.template.dart | 3 +- 2 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 test/services/tor_shutdown_test.dart diff --git a/test/services/tor_shutdown_test.dart b/test/services/tor_shutdown_test.dart new file mode 100644 index 0000000000..6701708998 --- /dev/null +++ b/test/services/tor_shutdown_test.dart @@ -0,0 +1,48 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/services/event_bus/events/global/tor_connection_status_changed_event.dart'; +import 'package:stackwallet/wl_gen/generated/tor_service_impl.dart'; +import 'package:tor_ffi_plugin/tor_ffi_plugin.dart'; + +class _Tor extends Fake implements Tor { + final disabled = Completer(); + var stopCalls = 0; + + @override + Future start({required String torDataDirPath}) async {} + + @override + Future disable() => disabled.future; + + @override + Future stop() async => stopCalls++; +} + +void main() { + test( + 'disable waits for native teardown before reporting disconnected', + () async { + final tor = _Tor(); + final service = torService; + // The generated implementation exposes the existing test injection hook. + (service as dynamic).init( + torDataDirPath: 'unused', + mockableOverride: tor, + ); + await service.start(); + expect(service.status, TorConnectionStatus.connected); + + var completed = false; + final disabling = service.disable().then((_) => completed = true); + await Future.delayed(Duration.zero); + expect(completed, isFalse); + expect(service.status, TorConnectionStatus.connected); + + tor.disabled.complete(); + await disabling; + expect(service.status, TorConnectionStatus.disconnected); + expect(tor.stopCalls, 0, reason: 'disable() owns native teardown'); + }, + ); +} diff --git a/tool/wl_templates/TOR_tor_service_impl.template.dart b/tool/wl_templates/TOR_tor_service_impl.template.dart index 63ee6ef0c5..819836bf9d 100644 --- a/tool/wl_templates/TOR_tor_service_impl.template.dart +++ b/tool/wl_templates/TOR_tor_service_impl.template.dart @@ -115,8 +115,7 @@ class _TorServiceImpl extends TorService { return; } - _tor!.disable(); - await _tor?.stop(); + await _tor!.disable(); _updateStatusAndFireEvent( status: TorConnectionStatus.disconnected, From a07d59fbc717c10a3c699b322b41f79faf1b1786 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 9 Oct 2026 14:38:19 -0500 Subject: [PATCH 22/32] fix(network): destroy completed probe sockets --- lib/utilities/test_monero_node_connection.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/utilities/test_monero_node_connection.dart b/lib/utilities/test_monero_node_connection.dart index b8d07afbd4..1eb8181fa8 100644 --- a/lib/utilities/test_monero_node_connection.dart +++ b/lib/utilities/test_monero_node_connection.dart @@ -103,7 +103,9 @@ Future testMoneroNodeConnection( Logging.instance.w("$e\n$s", error: e, stackTrace: s); return MoneroNodeConnectionResponse(null, null, null, false); } finally { - await socket?.close(); + // The probe is finished; close() could rethrow an already handled + // failure. + socket?.destroy(); } } else { final httpClient = HttpClient(); From 37965b2c60e7fe8dd57f5a3a277810d877d87852 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 9 Oct 2026 13:26:22 -0500 Subject: [PATCH 23/32] fix(openalias): compose the authenticated recipient --- lib/services/openalias/open_alias.dart | 23 ++++++++++++++----- .../openalias/open_alias_service.dart | 4 +--- lib/services/openalias/send_preview.dart | 1 + 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/lib/services/openalias/open_alias.dart b/lib/services/openalias/open_alias.dart index 9654a05956..540aeff262 100644 --- a/lib/services/openalias/open_alias.dart +++ b/lib/services/openalias/open_alias.dart @@ -1,17 +1,28 @@ +import 'package:doh_resolver/doh_resolver.dart'; import 'package:openalias/openalias.dart' as oa; -class OpenAliasRecipient extends oa.OpenAliasRecipient { +/// Stack Wallet presentation state for an OpenAlias recipient. +/// +/// The package-owned [resolved] value retains authenticated DNS evidence. This +/// wrapper deliberately does not subclass or recreate that non-forgeable type. +class OpenAliasRecipient { + final String domain; + final String address; // The alias as the user entered it, e.g. user@example.com. final String displayAlias; + final oa.OpenAliasRecipient? resolved; const OpenAliasRecipient({ - required super.domain, - required super.address, - super.application, - super.record, - super.dns, + required this.domain, + required this.address, String? displayAlias, + this.resolved, }) : displayAlias = displayAlias ?? domain; + + String? get application => resolved?.application; + oa.OpenAliasRecord? get record => resolved?.record; + AuthenticatedTxtResult? get dns => resolved?.dns; + DnsAuthentication? get authentication => resolved?.authentication; } String displayOpenAlias(String input) { diff --git a/lib/services/openalias/open_alias_service.dart b/lib/services/openalias/open_alias_service.dart index eaefe14273..b20bcf0d96 100644 --- a/lib/services/openalias/open_alias_service.dart +++ b/lib/services/openalias/open_alias_service.dart @@ -120,9 +120,7 @@ class OpenAliasService { return OpenAliasRecipient( domain: recipient.domain, address: recipient.address, - application: recipient.application, - record: recipient.record, - dns: recipient.dns, + resolved: recipient, ); } on OpenAliasException { rethrow; diff --git a/lib/services/openalias/send_preview.dart b/lib/services/openalias/send_preview.dart index 667dfb6b2a..2629acede0 100644 --- a/lib/services/openalias/send_preview.dart +++ b/lib/services/openalias/send_preview.dart @@ -132,6 +132,7 @@ class SendPreviewAttempt { domain: recipient.domain, address: recipient.address, displayAlias: displayOpenAlias(source), + resolved: recipient.resolved, ), ); } From 1a117476c0574954b3c6300199a83f1cfe480cd5 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 9 Oct 2026 13:26:28 -0500 Subject: [PATCH 24/32] fix(dev): replace resolver overrides without duplicates --- scripts/dev/use_openalias_packages.dart | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/scripts/dev/use_openalias_packages.dart b/scripts/dev/use_openalias_packages.dart index e46973ac5d..05cf06fd03 100644 --- a/scripts/dev/use_openalias_packages.dart +++ b/scripts/dev/use_openalias_packages.dart @@ -27,10 +27,24 @@ void main(List args) { throw StateError('Expected generated app dependency overrides'); } final overrides = []; + var skippingReplacedPackage = false; + final overrideKey = RegExp(r'^ ([A-Za-z0-9_]+):(?:\s.*)?$'); for (final line in lines.skip(start + 1)) { if (line.isNotEmpty && !line.startsWith(' ') && !line.startsWith('#')) { break; } + if (skippingReplacedPackage) { + final indentation = line.length - line.trimLeft().length; + if (line.trim().isNotEmpty && indentation > 2) { + continue; + } + skippingReplacedPackage = false; + } + final match = overrideKey.firstMatch(line); + if (match != null && packages.contains(match.group(1))) { + skippingReplacedPackage = true; + continue; + } overrides.add(line); } final localOverrides = packages.map((package) { From acff45543960e111a099a00e083734babdf56095 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 9 Oct 2026 13:38:39 -0500 Subject: [PATCH 25/32] test(openalias): close the live DNS transport --- test/services/openalias/open_alias_live_test.dart | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/services/openalias/open_alias_live_test.dart b/test/services/openalias/open_alias_live_test.dart index 224333d4f5..854fcc157b 100644 --- a/test/services/openalias/open_alias_live_test.dart +++ b/test/services/openalias/open_alias_live_test.dart @@ -15,8 +15,11 @@ void main() { ); final validate = Monero(CryptoCurrencyNetwork.main).validateAddress; test('native DNSSEC and Monero address validation run in Flutter', () async { - final dns = await DnssecResolver(transport: IoDohTransport()) - .lookupTxt('donate.getmonero.org'); + final transport = IoDohTransport(); + addTearDown(transport.close); + final dns = await DnssecResolver( + transport: transport, + ).lookupTxt('donate.getmonero.org'); expect(dns.authentication, DnsAuthentication.locallyValidated); final record = dns.records .map((r) => r.text) From 158b08fc163f2cd1ab4afd99ee58f541d17eb318 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 9 Oct 2026 16:44:14 -0500 Subject: [PATCH 26/32] fix(openalias): harden lookup lifecycle and input privacy --- lib/pages/send_view/send_view.dart | 47 +++++--- .../wallet_view/sub_widgets/desktop_send.dart | 45 +++++-- lib/services/openalias/open_alias.dart | 1 + .../openalias/open_alias_service.dart | 110 ++++++++++++++---- lib/services/openalias/send_recipient.dart | 17 ++- lib/utilities/address_utils.dart | 42 +++++-- test/address_utils_test.dart | 77 ++++++++++++ .../send_view/open_alias_preview_test.dart | 27 ++++- .../openalias/open_alias_packages_test.dart | 40 +++++++ .../openalias/open_alias_service_test.dart | 48 ++++++++ .../services/openalias/send_preview_test.dart | 18 +++ .../openalias/send_recipient_test.dart | 41 +++++++ 12 files changed, 451 insertions(+), 62 deletions(-) diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 78459ac36d..49b720cf82 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -29,6 +29,7 @@ import '../../providers/ui/fee_rate_type_state_provider.dart'; import '../../providers/ui/preview_tx_button_state_provider.dart'; import '../../providers/wallet/public_private_balance_state_provider.dart'; import '../../route_generator.dart'; +import '../../services/openalias/open_alias.dart'; import '../../services/openalias/send_preview.dart'; import '../../services/openalias/send_recipient.dart'; import '../../services/spark_names_service.dart'; @@ -302,10 +303,11 @@ class _SendViewState extends ConsumerState { Future _pasteAddress() async { final ClipboardData? data = await clipboard.getData(Clipboard.kTextPlain); if (data?.text != null && data!.text!.isNotEmpty) { - String content = data.text!.trim(); - if (content.contains("\n")) { - content = content.substring(0, content.indexOf("\n")).trim(); - } + String content = prepareSendRecipientInput( + data.text!, + supportsOpenAlias: coin is Monero, + validateAddress: coin.validateAddress, + ); try { final paymentData = AddressUtils.parsePaymentUri( @@ -373,7 +375,7 @@ class _SendViewState extends ConsumerState { // .state = true, // ); - Logging.instance.d("qrResult content: ${qrResult.rawContent}"); + Logging.instance.d("QR scan completed"); if (qrResult.rawContent == null) return; final paymentData = AddressUtils.parsePaymentUri( @@ -386,7 +388,11 @@ class _SendViewState extends ConsumerState { _applyUri(paymentData); } else { _setOpReturnData(null); - _address = qrResult.rawContent!.split("\n").first.trim(); + _address = prepareSendRecipientInput( + qrResult.rawContent!, + supportsOpenAlias: coin is Monero, + validateAddress: coin.validateAddress, + ); sendToController.text = _address ?? ""; _setValidAddressProviders(_address); @@ -915,7 +921,10 @@ class _SendViewState extends ConsumerState { onError: (error, stack) { Logging.instance.e( 'Send preview failed', - error: error, + // Alias failures may contain a recipient's domain. Keep it out of logs. + error: error is OpenAliasException + ? 'OpenAlias lookup failed' + : error, stackTrace: stack, ); showTransactionFailedDialog(context, error, isDesktop: false); @@ -1036,12 +1045,16 @@ class _SendViewState extends ConsumerState { final resolved = await attempt.resolve( supportsOpenAlias: coin is Monero, validateAddress: wallet.cryptoCurrency.validateAddress, - lookup: (input) => ref - .read(pSendOpenAliasService) - .resolve( - input, - validateAddress: wallet.cryptoCurrency.validateAddress, - ), + lookup: (input) { + final operation = ref + .read(pSendOpenAliasService) + .startResolve( + input, + validateAddress: wallet.cryptoCurrency.validateAddress, + ); + attempt.onClose(operation.cancel); + return operation.result; + }, ); attempt.checkCurrent(); final destination = resolved.destination; @@ -1505,6 +1518,14 @@ class _SendViewState extends ConsumerState { @override Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType"); + ref.listen( + prefsChangeNotifierProvider.select( + (prefs) => (prefs.externalCalls, prefs.useTor), + ), + (previous, next) { + if (previous != next) _preview.invalidate(); + }, + ); ref.listen(pSendAmount, (previous, next) { if (previous != next) _preview.invalidate(); }); 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 c2e16d5ad8..407312755c 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,7 @@ import '../../../../providers/ui/fee_rate_type_state_provider.dart'; import '../../../../providers/ui/preview_tx_button_state_provider.dart'; import '../../../../providers/wallet/desktop_fee_providers.dart'; import '../../../../providers/wallet/public_private_balance_state_provider.dart'; +import '../../../../services/openalias/open_alias.dart'; import '../../../../services/openalias/send_preview.dart'; import '../../../../services/openalias/send_recipient.dart'; import '../../../../services/spark_names_service.dart'; @@ -490,7 +491,10 @@ class _DesktopSendState extends ConsumerState { onError: (error, stack) { Logging.instance.e( 'Send preview failed', - error: error, + // Alias failures may contain a recipient's domain. Keep it out of logs. + error: error is OpenAliasException + ? 'OpenAlias lookup failed' + : error, stackTrace: stack, ); showTransactionFailedDialog(context, error, isDesktop: true); @@ -656,12 +660,16 @@ class _DesktopSendState extends ConsumerState { final resolved = await attempt.resolve( supportsOpenAlias: coin is Monero, validateAddress: wallet.cryptoCurrency.validateAddress, - lookup: (input) => ref - .read(pSendOpenAliasService) - .resolve( - input, - validateAddress: wallet.cryptoCurrency.validateAddress, - ), + lookup: (input) { + final operation = ref + .read(pSendOpenAliasService) + .startResolve( + input, + validateAddress: wallet.cryptoCurrency.validateAddress, + ); + attempt.onClose(operation.cancel); + return operation.result; + }, ); attempt.checkCurrent(); final destination = resolved.destination; @@ -970,7 +978,11 @@ class _DesktopSendState extends ConsumerState { _applyUri(paymentData); } else { _setOpReturnData(null); - _address = qrCodeData.split("\n").first.trim(); + _address = prepareSendRecipientInput( + qrCodeData, + supportsOpenAlias: coin is Monero, + validateAddress: coin.validateAddress, + ); sendToController.text = _address ?? ""; _setValidAddressProviders(_address); @@ -1106,10 +1118,11 @@ class _DesktopSendState extends ConsumerState { Future pasteAddress() async { final ClipboardData? data = await clipboard.getData(Clipboard.kTextPlain); if (data?.text != null && data!.text!.isNotEmpty) { - String content = data.text!.trim(); - if (content.contains("\n")) { - content = content.substring(0, content.indexOf("\n")).trim(); - } + String content = prepareSendRecipientInput( + data.text!, + supportsOpenAlias: coin is Monero, + validateAddress: coin.validateAddress, + ); try { final paymentData = AddressUtils.parsePaymentUri( @@ -1402,6 +1415,14 @@ class _DesktopSendState extends ConsumerState { @override Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType"); + ref.listen( + prefsChangeNotifierProvider.select( + (prefs) => (prefs.externalCalls, prefs.useTor), + ), + (previous, next) { + if (previous != next) _preview.invalidate(); + }, + ); ref.listen(pSendAmount, (previous, next) { if (previous != next) _preview.invalidate(); }); diff --git a/lib/services/openalias/open_alias.dart b/lib/services/openalias/open_alias.dart index 540aeff262..ad25535a72 100644 --- a/lib/services/openalias/open_alias.dart +++ b/lib/services/openalias/open_alias.dart @@ -26,6 +26,7 @@ class OpenAliasRecipient { } String displayOpenAlias(String input) { + normalizeOpenAlias(input); final name = input.trim().toLowerCase(); return name.endsWith('.') ? name.substring(0, name.length - 1) : name; } diff --git a/lib/services/openalias/open_alias_service.dart b/lib/services/openalias/open_alias_service.dart index b20bcf0d96..806bf5cc79 100644 --- a/lib/services/openalias/open_alias_service.dart +++ b/lib/services/openalias/open_alias_service.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'dart:math'; @@ -24,7 +25,7 @@ class OpenAliasService { final bool Function() _supportsTor; final List _trustedValidators; final ({InternetAddress host, int port}) Function() _torProxy; - late final Future Function(String, bool) _lookup; + final Future Function(String, bool)? _lookup; OpenAliasService({ bool Function()? externalCalls, @@ -37,13 +38,18 @@ class OpenAliasService { _useTor = useTor ?? (() => Prefs.instance.useTor), _supportsTor = supportsTor ?? (() => AppConfig.hasFeature(AppFeature.tor)), - _trustedValidators = trustedValidators ?? [DnssecResolver.validator], - _torProxy = - torProxy ?? (() => TorService.sharedInstance.getProxyInfo()) { - _lookup = lookup ?? _lookupDns; - } + // Keep the public injection parameter named lookup. + // ignore: prefer_initializing_formals + _lookup = lookup, + _trustedValidators = List.unmodifiable( + trustedValidators ?? [DnssecResolver.validator], + ), + _torProxy = torProxy ?? (() => TorService.sharedInstance.getProxyInfo()); - Future _lookupDns(String domain, bool useTor) async { + DnsOperation _startLookupDns( + String domain, + bool useTor, + ) { final proxy = useTor ? _torProxy() : null; final transport = IoDohTransport( proxy: proxy == null @@ -58,22 +64,38 @@ class OpenAliasService { connectionTimeout: useTor ? torConnectTimeout : directConnectTimeout, requestTimeout: useTor ? torRequestTimeout : directRequestTimeout, ); + bool allowed() { + if (!_externalCalls() || _useTor() != useTor) return false; + if (useTor) { + if (!_supportsTor()) return false; + final current = _torProxy(); + return current.host.address == proxy!.host.address && + current.port == proxy.port; + } + return true; + } + try { - return await DnssecResolver( + final operation = DnssecResolver( timeout: useTor ? torLookupTimeout : directLookupTimeout, - transport: OpenAliasDnsTransport(transport, () { - if (!_externalCalls() || _useTor() != useTor) return false; - if (useTor) { - if (!_supportsTor()) return false; - final current = _torProxy(); - return current.host.address == proxy!.host.address && - current.port == proxy.port; - } - return true; - }), - ).lookupTxt(domain); - } finally { - await transport.close(); + transport: OpenAliasDnsTransport(transport, allowed), + ).startTxtLookup(domain); + return DnsOperation( + operation.result + .then((result) { + if (!allowed()) { + throw const OpenAliasException( + 'Privacy settings changed. Please look up the alias again.', + ); + } + return result; + }) + .whenComplete(transport.close), + operation.cancel, + ); + } catch (_) { + unawaited(transport.close()); + rethrow; } } @@ -88,6 +110,46 @@ class OpenAliasService { Future resolve( String input, { required bool Function(String) validateAddress, + }) => startResolve(input, validateAddress: validateAddress).result; + + // Cancellation owns the whole DNSSEC chain, including pending key requests. + DnsOperation startResolve( + String input, { + required bool Function(String) validateAddress, + }) { + final result = Completer(); + DnsOperation? lookup; + _resolve( + input, + validateAddress: validateAddress, + startLookup: (domain, useTor) { + final injected = _lookup; + lookup = injected == null + ? _startLookupDns(domain, useTor) + : DnsOperation(injected(domain, useTor), () {}); + return lookup!.result; + }, + ).then( + (recipient) { + if (!result.isCompleted) result.complete(recipient); + }, + onError: (Object error, StackTrace stack) { + if (!result.isCompleted) result.completeError(error, stack); + }, + ); + return DnsOperation(result.future, () { + if (result.isCompleted) return; + result.completeError( + const OpenAliasException('OpenAlias lookup cancelled.'), + ); + lookup?.cancel(); + }); + } + + Future _resolve( + String input, { + required bool Function(String) validateAddress, + required Future Function(String, bool) startLookup, }) async { final domain = normalizeOpenAlias(input); if (!_externalCalls()) { @@ -108,8 +170,10 @@ class OpenAliasService { trustedValidators: _trustedValidators, validateAddress: validateAddress, lookup: (name) async { - final result = await _lookup(name, useTor); - if (_useTor() != useTor || !_externalCalls()) { + final result = await startLookup(name, useTor); + if (_useTor() != useTor || + !_externalCalls() || + (useTor && !_supportsTor())) { throw const OpenAliasException( 'Privacy settings changed. Please look up the alias again.', ); diff --git a/lib/services/openalias/send_recipient.dart b/lib/services/openalias/send_recipient.dart index b6df0bf407..e962917764 100644 --- a/lib/services/openalias/send_recipient.dart +++ b/lib/services/openalias/send_recipient.dart @@ -23,7 +23,7 @@ class SendRecipient { try { return SendRecipient._( SendRecipientKind.openAlias, - normalizeOpenAlias(destination), + normalizeOpenAlias(input), ); } on OpenAliasException { // Invalid input stays editable without initiating a lookup. @@ -34,3 +34,18 @@ class SendRecipient { bool get isAlias => kind == SendRecipientKind.openAlias; } + +// Pasted/scanned literal addresses retain the existing first-line cleanup. +// Aliases must reach normalization unchanged so hidden input stays invalid. +String prepareSendRecipientInput( + String input, { + required bool supportsOpenAlias, + required bool Function(String) validateAddress, +}) { + final destination = input.trim().split('\n').first.trim(); + // Payment URIs retain paste cleanup; a URI cannot be an OpenAlias. + if (Uri.tryParse(destination)?.hasScheme ?? false) return destination; + return supportsOpenAlias && !validateAddress(destination) + ? input + : destination; +} diff --git a/lib/utilities/address_utils.dart b/lib/utilities/address_utils.dart index cb57e48e81..95f9370b74 100644 --- a/lib/utilities/address_utils.dart +++ b/lib/utilities/address_utils.dart @@ -83,8 +83,8 @@ class AddressUtils { rethrow; } catch (e, s) { Logging.instance.d( - "Exception caught in parseUri($uri): $e", - error: e, + "Payment URI parsing failed", + error: e.runtimeType.toString(), stackTrace: s, ); } @@ -140,10 +140,11 @@ class AddressUtils { // hacky check its not just a bcash, ecash, or xel address const cashAddrSchemes = {"bitcoincash", "bchtest", "ecash", "ectest"}; final parsedUri = Uri.tryParse(uri); - final scheme = parsedUri?.scheme.toLowerCase(); - if (parsedUri != null && - (scheme == "xel" || - (!parsedUri.hasQuery && cashAddrSchemes.contains(scheme)))) { + // Plain addresses and aliases are normal input, not failed payment URIs. + if (parsedUri == null || !parsedUri.hasScheme) return null; + final scheme = parsedUri.scheme.toLowerCase(); + if (scheme == "xel" || + (!parsedUri.hasQuery && cashAddrSchemes.contains(scheme))) { return null; } @@ -153,7 +154,16 @@ class AddressUtils { // Normalize the URI scheme. final String scheme = parsedData['scheme'] ?? ''; parsedData.remove('scheme'); - final address = parsedData['address']!.trim(); + final rawAddress = parsedData['address']!; + // Monero destinations are printable ASCII. Do not strip controls from an + // alias inside a payment URI before the alias validator can reject it. + if (scheme == 'monero' && + rawAddress.codeUnits.any( + (unit) => unit > 126 || (unit < 32 && unit != 9), + )) { + throw const FormatException('Invalid characters in Monero recipient'); + } + final address = rawAddress.trim(); // Filter out unrecognized parameters. final filteredParams = _filterParams(parsedData); @@ -171,7 +181,12 @@ class AddressUtils { additionalParams: filteredParams, ); } catch (e, s) { - logging?.i("Invalid payment URI: $uri", error: e, stackTrace: s); + // URI text and exception messages can contain payment recipients/notes. + logging?.i( + "Invalid payment URI", + error: e.runtimeType.toString(), + stackTrace: s, + ); return null; } } @@ -220,14 +235,17 @@ class AddressUtils { } /// returns empty if bad data - static Map decodeQRSeedData(String data) { + static Map decodeQRSeedData( + String data, { + Logging? logging, + }) { Map result = {}; try { result = Map.from(jsonDecode(data) as Map); } catch (e, s) { - Logging.instance.d( - "Exception caught in parseQRSeedData($data)", - error: e, + (logging ?? Logging.instance).d( + "QR seed data parsing failed", + error: e.runtimeType.toString(), stackTrace: s, ); } diff --git a/test/address_utils_test.dart b/test/address_utils_test.dart index dc1bd24e74..40ef880221 100644 --- a/test/address_utils_test.dart +++ b/test/address_utils_test.dart @@ -1,8 +1,85 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:stackwallet/utilities/address_utils.dart'; +import 'package:stackwallet/utilities/logger.dart'; import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +class _RecordingLog extends Fake implements Logging { + final entries = []; + @override + void d( + dynamic message, { + DateTime? time, + Object? error, + StackTrace? stackTrace, + }) { + entries.add('$message $error'); + } + + @override + void i( + dynamic message, { + DateTime? time, + Object? error, + StackTrace? stackTrace, + }) { + entries.add('$message $error'); + } +} + void main() { + test('malformed seed QR does not expose mnemonic data in logs', () { + final log = _RecordingLog(); + expect( + AddressUtils.decodeQRSeedData('{"mnemonic":["secret-seed"', logging: log), + isEmpty, + ); + expect(log.entries.single, 'QR seed data parsing failed FormatException'); + }); + + test('Monero payment URIs cannot erase hidden alias characters', () { + for (final character in [ + '\ufeff', + '\u00a0', + '\u2003', + '\r', + '\n', + '\v', + '\f', + '\x7f', + ]) { + expect( + AddressUtils.parsePaymentUri('monero:${character}alice.example'), + isNull, + ); + expect( + AddressUtils.parsePaymentUri('monero:alice.example$character'), + isNull, + ); + } + }); + test('pasted and typed aliases do not enter payment URI logs', () { + final log = _RecordingLog(); + for (final input in [ + 'alice.example', + 'Alice@Example', + '\u00a0alice.example', + ]) { + expect(AddressUtils.parsePaymentUri(input, logging: log), isNull); + } + expect(log.entries, isEmpty); + }); + test('invalid payment URI logs do not expose recipient data', () { + final log = _RecordingLog(); + expect( + AddressUtils.parsePaymentUri( + 'monero:alice.example?amount=private-note', + logging: log, + ), + isNull, + ); + expect(log.entries.single, 'Invalid payment URI FormatException'); + }); + const String firoAddress = "a6ESWKz7szru5syLtYAPRhHLdKvMq3Yt1j"; test("condense address", () { diff --git a/test/pages/send_view/open_alias_preview_test.dart b/test/pages/send_view/open_alias_preview_test.dart index 54e89bebe6..1698ecde36 100644 --- a/test/pages/send_view/open_alias_preview_test.dart +++ b/test/pages/send_view/open_alias_preview_test.dart @@ -134,6 +134,14 @@ class _Prefs extends ChangeNotifier implements Prefs { String get currency => 'USD'; @override bool get externalCalls => false; + bool _useTor = false; + @override + bool get useTor => _useTor; + void changeTor(bool value) { + _useTor = value; + notifyListeners(); + } + @override bool get enableCoinControl => false; @override @@ -165,6 +173,7 @@ class _Harness { final identity = ValueNotifier('wallet'); final visible = ValueNotifier(true); final auth = _AuthObserver(); + final prefs = _Prefs(); final lookups = >[]; int get calls => lookups.length; _Harness(this.desktop); @@ -203,7 +212,7 @@ class _Harness { pWalletName(id).overrideWithValue('Test wallet'), pWalletBalance(id).overrideWithValue(wallet.info.cachedBalance), ], - prefsChangeNotifierProvider.overrideWithValue(_Prefs()), + prefsChangeNotifierProvider.overrideWithValue(prefs), coinIconProvider(coin) .overrideWithValue('test/sample_data/light/assets/dummy.svg'), coinImageSecondaryProvider(coin).overrideWithValue('dummy.svg'), @@ -346,6 +355,18 @@ class _Harness { void main() { for (final desktop in [false, true]) { final layout = desktop ? 'desktop 1200x900' : 'mobile 390x844'; + testWidgets('$layout rejects aliases with hidden boundary characters', ( + tester, + ) async { + final h = _Harness(desktop); + await h.mount(tester); + await h.setAmount(tester); + for (final value in ['\u00a0Alice@Example', 'Alice@Example\ufeff']) { + await h.enter(tester, value); + expect(h.enabled(tester), isFalse); + expect(h.calls, 0); + } + }); testWidgets( '$layout resolves on Preview and shows each prepared recipient', (tester) async { @@ -431,6 +452,7 @@ void main() { for (final change in [ 'edit away and back', 'amount', + 'privacy away and back', 'cancel', 'wallet', 'dispose', @@ -449,6 +471,9 @@ void main() { controller.text = 'Alice@Example'; case 'amount': h.container.read(pSendAmount.notifier).state = amount(2); + case 'privacy away and back': + h.prefs.changeTor(true); + h.prefs.changeTor(false); case 'cancel': await tester.tap(find.text('Cancel')); case 'wallet': diff --git a/test/services/openalias/open_alias_packages_test.dart b/test/services/openalias/open_alias_packages_test.dart index c33305eb73..8a23b7f9a5 100644 --- a/test/services/openalias/open_alias_packages_test.dart +++ b/test/services/openalias/open_alias_packages_test.dart @@ -243,6 +243,46 @@ void main() { ); }, ); + test('cancelled lookup closes its pending Tor connection', () async { + final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(server.close); + final connected = Completer(); + final disconnected = Completer(); + server.listen((socket) { + addTearDown(socket.destroy); + socket.listen( + (_) { + if (!connected.isCompleted) connected.complete(); + // Leave the SOCKS handshake pending until the lookup is cancelled. + }, + onDone: () { + if (!disconnected.isCompleted) disconnected.complete(); + }, + onError: (Object _) { + if (!disconnected.isCompleted) disconnected.complete(); + }, + ); + }); + final service = OpenAliasService( + externalCalls: () => true, + useTor: () => true, + supportsTor: () => true, + torProxy: () => (host: InternetAddress.loopbackIPv4, port: server.port), + ); + final operation = service.startResolve( + 'alice.example', + validateAddress: (_) => true, + ); + final rejected = expectLater( + operation.result, + throwsA(isA()), + ); + await connected.future.timeout(const Duration(seconds: 5)); + operation.cancel(); + await rejected; + await disconnected.future.timeout(const Duration(seconds: 5)); + }); + test('each Tor lookup uses its own SOCKS isolation credentials', () async { final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); addTearDown(server.close); diff --git a/test/services/openalias/open_alias_service_test.dart b/test/services/openalias/open_alias_service_test.dart index d52c44662f..9b2549ea43 100644 --- a/test/services/openalias/open_alias_service_test.dart +++ b/test/services/openalias/open_alias_service_test.dart @@ -69,6 +69,54 @@ void main() { await expectLater(future, throwsA(isA())); }, ); + test( + 'cancellation settles promptly and ignores a late successful lookup', + () async { + final pending = Completer(); + final service = OpenAliasService( + externalCalls: () => true, + useTor: () => false, + trustedValidators: testValidators, + lookup: (_, _) => pending.future, + ); + final operation = service.startResolve( + 'alice.example', + validateAddress: (_) => true, + ); + final result = expectLater( + operation.result, + throwsA(isA()), + ); + operation.cancel(); + operation.cancel(); + await result; + pending.complete( + authenticatedTxt('alice.example', ['oa1:xmr recipient_address=valid;']), + ); + await Future.delayed(Duration.zero); + }, + ); + + test( + 'caller mutations cannot extend the wallet validator allowlist', + () async { + final trusted = []; + final service = OpenAliasService( + externalCalls: () => true, + useTor: () => false, + trustedValidators: trusted, + lookup: (_, _) async => authenticatedTxt('alice.example', [ + 'oa1:xmr recipient_address=valid;', + ]), + ); + trusted.addAll(testValidators); + await expectLater( + service.resolve('alice.example', validateAddress: (_) => true), + throwsA(isA()), + ); + }, + ); + test('returns only a validated literal recipient', () async { final service = OpenAliasService( externalCalls: () => true, diff --git a/test/services/openalias/send_preview_test.dart b/test/services/openalias/send_preview_test.dart index a2fd7f8245..58d2005ef6 100644 --- a/test/services/openalias/send_preview_test.dart +++ b/test/services/openalias/send_preview_test.dart @@ -4,6 +4,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:stackwallet/services/openalias/open_alias.dart'; import 'package:stackwallet/services/openalias/send_preview.dart'; +import 'open_alias_test_fixtures.dart'; + void main() { late SendPreview preview; late List prepared; @@ -68,6 +70,22 @@ void main() { expect(errors, isEmpty); }, ); + test('confirmation preserves the validated DNS provenance', () async { + final dns = authenticatedTxt('alice.example', [ + 'oa1:xmr recipient_address=literal;', + ]); + await run( + pending: Future.value( + OpenAliasRecipient( + domain: 'alice.example', + address: 'literal', + dns: dns, + ), + ), + ); + expect(prepared.single.alias!.dns, same(dns)); + }); + test('lookup failures do not prepare', () async { await run(pending: Future.error(const OpenAliasException('DNSSEC failed'))); expect(prepared, isEmpty); diff --git a/test/services/openalias/send_recipient_test.dart b/test/services/openalias/send_recipient_test.dart index ec9b0dd4df..938e1fce8f 100644 --- a/test/services/openalias/send_recipient_test.dart +++ b/test/services/openalias/send_recipient_test.dart @@ -9,6 +9,44 @@ void main() { validateAddress: (value) => value == 'literal-address', ); + test('paste and QR cleanup preserve raw aliases for validation', () { + for (final input in [ + '\u00a0alice.example', + 'alice.example\ufeff', + 'alice.example\nignored', + ]) { + final prepared = prepareSendRecipientInput( + input, + supportsOpenAlias: true, + validateAddress: (value) => value == 'literal-address', + ); + expect(prepared, input); + expect(classify(prepared).kind, SendRecipientKind.invalid); + } + expect( + prepareSendRecipientInput( + ' literal-address\nignored ', + supportsOpenAlias: true, + validateAddress: (value) => value == 'literal-address', + ), + 'literal-address', + ); + }); + + test('pasted payment URIs retain whitespace and first-line cleanup', () { + const uri = 'monero:literal-address?tx_amount=1'; + for (final input in [' $uri ', '$uri\nignored']) { + expect( + prepareSendRecipientInput( + input, + supportsOpenAlias: true, + validateAddress: (_) => false, + ), + uri, + ); + } + }); + test('literal addresses take precedence and preserve case', () { final result = classify(' literal-address '); expect(result.kind, SendRecipientKind.literal); @@ -41,6 +79,9 @@ void main() { '127.0.0.1', 'a.onion', 'ü.example', + '\u00a0alice.example', + 'alice.example\ufeff', + '\u2003alice.example', 'monero:literal-address', ]) { expect(classify(input).kind, SendRecipientKind.invalid, reason: input); From 1d2237cf56829633ab3421f5a290c0dd12903d5d Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 9 Oct 2026 16:44:14 -0500 Subject: [PATCH 27/32] test(dev): cover local OpenAlias package overrides --- scripts/dev/test_use_openalias_packages.py | 62 ++++++++++++++++++++++ scripts/dev/use_openalias_packages.dart | 7 +-- 2 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 scripts/dev/test_use_openalias_packages.py diff --git a/scripts/dev/test_use_openalias_packages.py b/scripts/dev/test_use_openalias_packages.py new file mode 100644 index 0000000000..ab9d2a9b8c --- /dev/null +++ b/scripts/dev/test_use_openalias_packages.py @@ -0,0 +1,62 @@ +"""Exercise local resolver overrides against a configured app with Git pins. + +Run with python3 scripts/dev/test_use_openalias_packages.py; requires Dart. +No network or wallet state is used. +""" + +import json +from pathlib import Path +import subprocess +import tempfile +import unittest + + +HELPER = Path(__file__).with_name('use_openalias_packages.dart').resolve() + + +class LocalOverridesTest(unittest.TestCase): + def test_replaces_existing_pins_and_preserves_other_overrides(self): + with tempfile.TemporaryDirectory(prefix='alias overrides ') as directory: + root = Path(directory) + packages = ['doh_resolver', 'dnssec_resolver', 'openalias', 'keep'] + for name in packages: + package = root / name + package.mkdir() + (package / 'pubspec.yaml').write_text( + f'name: {name}\nversion: 0.0.1\n' + 'environment:\n sdk: ">=3.5.0 <4.0.0"\n' + ) + app = root / 'app' + app.mkdir() + dependencies = ''.join(f' {name}: any\n' for name in packages) + pins = ''.join( + f' {name}:\n git:\n' + f' url: https://invalid.example/{name}.git\n' + ' ref: main\n' + for name in packages[:-1] + ) + (app / 'pubspec.yaml').write_text( + 'name: override_fixture\nenvironment:\n' + ' sdk: ">=3.5.0 <4.0.0"\ndependencies:\n' + dependencies + + 'dependency_overrides:\n' + pins + + ' keep:\n path: ../keep\n' + ) + def run(*args): + return subprocess.run( + ['dart', *args], cwd=app, check=True, + capture_output=True, text=True, timeout=60, + ) + run(str(HELPER), str(root)) + first = (app / 'pubspec_overrides.yaml').read_text() + run(str(HELPER), str(root)) + self.assertEqual(first, (app / 'pubspec_overrides.yaml').read_text()) + run('pub', 'get', '--offline') + config = json.loads((app / '.dart_tool/package_config.json').read_text()) + resolved = {item['name']: item for item in config['packages']} + for name in packages: + uri = resolved[name]['rootUri'] + self.assertTrue(uri.endswith(f'/{name}') or uri.endswith(f'/{name}/')) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/dev/use_openalias_packages.dart b/scripts/dev/use_openalias_packages.dart index 05cf06fd03..3c96cf6e40 100644 --- a/scripts/dev/use_openalias_packages.dart +++ b/scripts/dev/use_openalias_packages.dart @@ -8,12 +8,7 @@ void main(List args) { ); } final workspace = Directory(args.single).absolute; - const packages = [ - 'socks_socket', - 'doh_resolver', - 'dnssec_resolver', - 'openalias', - ]; + const packages = ['doh_resolver', 'dnssec_resolver', 'openalias']; for (final package in packages) { if (!File('${workspace.path}/$package/pubspec.yaml').existsSync()) { throw ArgumentError('Missing package $package in ${workspace.path}'); From 16c33530da602d17d6b88dfb4d479bf780e7c951 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 9 Oct 2026 16:44:14 -0500 Subject: [PATCH 28/32] fix(macos): normalize legacy frameworks before signing --- macos/Podfile | 9 + scripts/macos/normalize_frameworks.rb | 111 +++++++++ scripts/macos/test/fixtures/COCOAPODS_LICENSE | 33 +++ scripts/macos/test/fixtures/README.md | 10 + .../test/fixtures/cocoapods-frameworks.sh | 216 ++++++++++++++++++ .../macos/test/normalize_frameworks_test.rb | 175 ++++++++++++++ 6 files changed, 554 insertions(+) create mode 100644 scripts/macos/normalize_frameworks.rb create mode 100644 scripts/macos/test/fixtures/COCOAPODS_LICENSE create mode 100644 scripts/macos/test/fixtures/README.md create mode 100644 scripts/macos/test/fixtures/cocoapods-frameworks.sh create mode 100644 scripts/macos/test/normalize_frameworks_test.rb diff --git a/macos/Podfile b/macos/Podfile index e28f7e3b51..09e521a3ec 100644 --- a/macos/Podfile +++ b/macos/Podfile @@ -45,3 +45,12 @@ post_install do |installer| end end end + +# Hosted legacy framework archives flattened their version symlinks. Repair only +# staged copies and retain CocoaPods' configured signing identity/permissions. +require_relative '../scripts/macos/normalize_frameworks' +post_integrate do |installer| + scripts = Dir[File.join(installer.sandbox.root.to_s, 'Target Support Files', 'Pods-Runner', 'Pods-Runner-frameworks.sh')] + raise 'Expected exactly one Runner framework embed script' unless scripts.length == 1 + StackFrameworkPackaging.patch_embed_script(scripts.first) +end diff --git a/scripts/macos/normalize_frameworks.rb b/scripts/macos/normalize_frameworks.rb new file mode 100644 index 0000000000..f9a74d89e7 --- /dev/null +++ b/scripts/macos/normalize_frameworks.rb @@ -0,0 +1,111 @@ +# Repair flattened symlinks in the three legacy hosted frameworks, only after +# CocoaPods has copied them into the app. Never modify package/cache sources. +require 'digest' +require 'fileutils' +require 'pathname' +require 'shellwords' + +module StackFrameworkPackaging + NAMES = %w[MoneroWallet WowneroWallet SalviumWallet].freeze + PARALLEL = 'COCOAPODS_PARALLEL_CODE_SIGN="${COCOAPODS_PARALLEL_CODE_SIGN:-false}"'.freeze + INSERTION = " local basename\n".freeze + MARKER = '# Stack Wallet: validate staged legacy framework layout before stripping/signing.'.freeze + + def self.inventory(path) + raise "Expected a real directory: #{path}" unless path.directory? && !path.symlink? + entries = {} + path.children.sort.each do |entry| + raise "Unexpected symlink in flattened framework: #{entry}" if entry.symlink? + if entry.directory? + entries[entry.basename.to_s] = [:directory, inventory(entry)] + elsif entry.file? + entries[entry.basename.to_s] = [:file, entry.size, Digest::SHA256.file(entry).hexdigest] + else + raise "Unexpected filesystem entry: #{entry}" + end + end + entries + end + + def self.normalize(staging_root, framework) + path = Pathname.new(framework).expand_path + name = path.basename.to_s.delete_suffix('.framework') + return unless NAMES.include?(name) && path.basename.to_s == "#{name}.framework" + + root = Pathname.new(staging_root).realpath + unless !path.symlink? && path.directory? && path.parent.realpath == root + raise "Framework must be a direct staged copy: #{path}" + end + unless path.children.map { |entry| entry.basename.to_s }.sort == [name, 'Resources', 'Versions'].sort + raise "Unexpected top-level framework entries: #{path}" + end + versions = path / 'Versions' + canonical = versions / 'A' + raise "Unexpected Versions symlink: #{versions}" if versions.symlink? + unless versions.children.map { |entry| entry.basename.to_s }.sort == %w[A Current] + raise "Unexpected framework versions: #{versions}" + end + canonical_inventory = inventory(canonical) + unless (canonical / name).file? && (canonical / 'Resources').directory? + raise "Missing canonical framework binary/resources: #{path}" + end + links = { + versions / 'Current' => ['A', canonical], + path / name => ["Versions/Current/#{name}", canonical / name], + path / 'Resources' => ['Versions/Current/Resources', canonical / 'Resources'], + } + # Validate every entry before any mutation, including partially repaired layouts. + links.each do |entry, (target, original)| + if entry.symlink? + raise "Unexpected framework link: #{entry}" unless entry.readlink.to_s == target + elsif original.directory? + expected = original == canonical ? canonical_inventory : inventory(original) + raise "Nonidentical framework directory: #{entry}" unless inventory(entry) == expected + else + unless entry.file? && FileUtils.compare_file(entry, original) + raise "Nonidentical framework binary: #{entry}" + end + end + end + links.each do |entry, (target, _original)| + next if entry.symlink? + entry.directory? ? FileUtils.remove_entry(entry.to_s) : entry.unlink + entry.make_symlink(target) + end + end + + def self.patch_embed_script(path) + script = File.read(path) + helper = File.expand_path(__FILE__) + command = " /usr/bin/ruby #{Shellwords.escape(helper)} normalize \"${destination}\" \"${destination}/$(basename \"$1\")\"\n" + replacement = " #{MARKER}\n#{command}#{INSERTION}" + if script.include?(MARKER) + unless script.scan(replacement).length == 1 && script.scan('COCOAPODS_PARALLEL_CODE_SIGN=false').length == 1 && !script.include?(PARALLEL) + raise "Unexpected previously patched CocoaPods embed script: #{path}" + end + return + end + boundaries = /install_framework\(\)\n\{\n.*?\n\}\n/m + sections = script.scan(boundaries) + raise "Expected one install_framework function: #{path}" unless sections.length == 1 + section = sections.first + copy = ' rsync --delete -av ' + + sign = ' code_sign_if_enabled "${destination}/$(basename "$1")"' + unless script.scan(PARALLEL).length == 1 && section.scan(INSERTION).length == 1 && + section.scan(copy).length == 1 && section.scan(sign).length == 1 && + section.index(copy) < section.index(INSERTION) && section.index(INSERTION) < section.index(sign) + raise "CocoaPods embed script changed; review staged framework repair integration: #{path}" + end + script = script.sub(PARALLEL) { 'COCOAPODS_PARALLEL_CODE_SIGN=false' } + script = script.sub(section) { section.sub(INSERTION) { replacement } } + File.write(path, script) + end +end + +if $PROGRAM_NAME == __FILE__ + unless ARGV.length == 3 && ARGV[0] == 'normalize' + abort 'Usage: normalize_frameworks.rb normalize STAGING_FRAMEWORKS_DIR FRAMEWORK' + end + StackFrameworkPackaging.normalize(ARGV[1], ARGV[2]) +end diff --git a/scripts/macos/test/fixtures/COCOAPODS_LICENSE b/scripts/macos/test/fixtures/COCOAPODS_LICENSE new file mode 100644 index 0000000000..6e8a60d2df --- /dev/null +++ b/scripts/macos/test/fixtures/COCOAPODS_LICENSE @@ -0,0 +1,33 @@ +This project is licensed under the MIT license. + +Copyright (c) 2011 Eloy Durán , + Fabio Pelosin , + Samuel Giddins , + Marius Rackwitz , + Kyle Fuller , + Boris Bügling , + Orta Therox , + Olivier Halligon , + Danielle Tomlinson , + Dimitris Koutsogiorgas , + Paul Beusterien , and + Eric Amorde . + + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/scripts/macos/test/fixtures/README.md b/scripts/macos/test/fixtures/README.md new file mode 100644 index 0000000000..a5fdefc920 --- /dev/null +++ b/scripts/macos/test/fixtures/README.md @@ -0,0 +1,10 @@ +`cocoapods-frameworks.sh` was generated by CocoaPods 1.16.2 for this +workspace's Runner target. It preserves the real install_framework, +install_dsym, repeated rsync calls, and signing routines so our integration +checks fail when function-boundary assumptions are wrong. It is a test fixture, +not an executed application build script. + +The generator is CocoaPods' `lib/cocoapods/generator/embed_frameworks_script.rb`. +Its MIT license is reproduced in `COCOAPODS_LICENSE`. Regenerate the fixture +when intentionally supporting a changed CocoaPods script format, then rerun +`ruby scripts/macos/test/normalize_frameworks_test.rb`. diff --git a/scripts/macos/test/fixtures/cocoapods-frameworks.sh b/scripts/macos/test/fixtures/cocoapods-frameworks.sh new file mode 100644 index 0000000000..5a51f2f99e --- /dev/null +++ b/scripts/macos/test/fixtures/cocoapods-frameworks.sh @@ -0,0 +1,216 @@ +#!/bin/sh +set -e +set -u +set -o pipefail + +function on_error { + echo "$(realpath -mq "${0}"):$1: error: Unexpected failure" +} +trap 'on_error $LINENO' ERR + +if [ -z ${FRAMEWORKS_FOLDER_PATH+x} ]; then + # If FRAMEWORKS_FOLDER_PATH is not set, then there's nowhere for us to copy + # frameworks to, so exit 0 (signalling the script phase was successful). + exit 0 +fi + +echo "mkdir -p ${CONFIGURATION_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}" +mkdir -p "${CONFIGURATION_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}" + +COCOAPODS_PARALLEL_CODE_SIGN="${COCOAPODS_PARALLEL_CODE_SIGN:-false}" +SWIFT_STDLIB_PATH="${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" +BCSYMBOLMAP_DIR="BCSymbolMaps" + + +# This protects against multiple targets copying the same framework dependency at the same time. The solution +# was originally proposed here: https://lists.samba.org/archive/rsync/2008-February/020158.html +RSYNC_PROTECT_TMP_FILES=(--filter "P .*.??????") + +# Copies and strips a vendored framework +install_framework() +{ + if [ -r "${BUILT_PRODUCTS_DIR}/$1" ]; then + local source="${BUILT_PRODUCTS_DIR}/$1" + elif [ -r "${BUILT_PRODUCTS_DIR}/$(basename "$1")" ]; then + local source="${BUILT_PRODUCTS_DIR}/$(basename "$1")" + elif [ -r "$1" ]; then + local source="$1" + fi + + local destination="${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}" + + if [ -L "${source}" ]; then + echo "Symlinked..." + source="$(readlink -f "${source}")" + fi + + if [ -d "${source}/${BCSYMBOLMAP_DIR}" ]; then + # Locate and install any .bcsymbolmaps if present, and remove them from the .framework before the framework is copied + find "${source}/${BCSYMBOLMAP_DIR}" -name "*.bcsymbolmap"|while read f; do + echo "Installing $f" + install_bcsymbolmap "$f" "$destination" + rm "$f" + done + rmdir "${source}/${BCSYMBOLMAP_DIR}" + fi + + # Use filter instead of exclude so missing patterns don't throw errors. + echo "rsync --delete -av "${RSYNC_PROTECT_TMP_FILES[@]}" --links --filter \"- CVS/\" --filter \"- .svn/\" --filter \"- .git/\" --filter \"- .hg/\" --filter \"- Headers\" --filter \"- PrivateHeaders\" --filter \"- Modules\" \"${source}\" \"${destination}\"" + rsync --delete -av "${RSYNC_PROTECT_TMP_FILES[@]}" --links --filter "- CVS/" --filter "- .svn/" --filter "- .git/" --filter "- .hg/" --filter "- Headers" --filter "- PrivateHeaders" --filter "- Modules" "${source}" "${destination}" + + local basename + basename="$(basename -s .framework "$1")" + binary="${destination}/${basename}.framework/${basename}" + + if ! [ -r "$binary" ]; then + binary="${destination}/${basename}" + elif [ -L "${binary}" ]; then + echo "Destination binary is symlinked..." + dirname="$(dirname "${binary}")" + binary="${dirname}/$(readlink "${binary}")" + fi + + # Strip invalid architectures so "fat" simulator / device frameworks work on device + if [[ "$(file "$binary")" == *"dynamically linked shared library"* ]]; then + strip_invalid_archs "$binary" + fi + + # Resign the code if required by the build settings to avoid unstable apps + code_sign_if_enabled "${destination}/$(basename "$1")" + + # Embed linked Swift runtime libraries. No longer necessary as of Xcode 7. + if [ "${XCODE_VERSION_MAJOR}" -lt 7 ]; then + local swift_runtime_libs + swift_runtime_libs=$(xcrun otool -LX "$binary" | grep --color=never @rpath/libswift | sed -E s/@rpath\/\(.+dylib\).*/\1/g | uniq -u) + for lib in $swift_runtime_libs; do + echo "rsync -auv \"${SWIFT_STDLIB_PATH}/${lib}\" \"${destination}\"" + rsync -auv "${SWIFT_STDLIB_PATH}/${lib}" "${destination}" + code_sign_if_enabled "${destination}/${lib}" + done + fi +} +# Copies and strips a vendored dSYM +install_dsym() { + local source="$1" + warn_missing_arch=${2:-true} + if [ -r "$source" ]; then + # Copy the dSYM into the targets temp dir. + echo "rsync --delete -av "${RSYNC_PROTECT_TMP_FILES[@]}" --filter \"- CVS/\" --filter \"- .svn/\" --filter \"- .git/\" --filter \"- .hg/\" --filter \"- Headers\" --filter \"- PrivateHeaders\" --filter \"- Modules\" \"${source}\" \"${DERIVED_FILES_DIR}\"" + rsync --delete -av "${RSYNC_PROTECT_TMP_FILES[@]}" --filter "- CVS/" --filter "- .svn/" --filter "- .git/" --filter "- .hg/" --filter "- Headers" --filter "- PrivateHeaders" --filter "- Modules" "${source}" "${DERIVED_FILES_DIR}" + + local basename + basename="$(basename -s .dSYM "$source")" + binary_name="$(ls "$source/Contents/Resources/DWARF")" + binary="${DERIVED_FILES_DIR}/${basename}.dSYM/Contents/Resources/DWARF/${binary_name}" + + # Strip invalid architectures from the dSYM. + if [[ "$(file "$binary")" == *"Mach-O "*"dSYM companion"* ]]; then + strip_invalid_archs "$binary" "$warn_missing_arch" + fi + if [[ $STRIP_BINARY_RETVAL == 0 ]]; then + # Move the stripped file into its final destination. + echo "rsync --delete -av "${RSYNC_PROTECT_TMP_FILES[@]}" --links --filter \"- CVS/\" --filter \"- .svn/\" --filter \"- .git/\" --filter \"- .hg/\" --filter \"- Headers\" --filter \"- PrivateHeaders\" --filter \"- Modules\" \"${DERIVED_FILES_DIR}/${basename}.framework.dSYM\" \"${DWARF_DSYM_FOLDER_PATH}\"" + rsync --delete -av "${RSYNC_PROTECT_TMP_FILES[@]}" --links --filter "- CVS/" --filter "- .svn/" --filter "- .git/" --filter "- .hg/" --filter "- Headers" --filter "- PrivateHeaders" --filter "- Modules" "${DERIVED_FILES_DIR}/${basename}.dSYM" "${DWARF_DSYM_FOLDER_PATH}" + else + # The dSYM was not stripped at all, in this case touch a fake folder so the input/output paths from Xcode do not reexecute this script because the file is missing. + mkdir -p "${DWARF_DSYM_FOLDER_PATH}" + touch "${DWARF_DSYM_FOLDER_PATH}/${basename}.dSYM" + fi + fi +} + +# Used as a return value for each invocation of `strip_invalid_archs` function. +STRIP_BINARY_RETVAL=0 + +# Strip invalid architectures +strip_invalid_archs() { + binary="$1" + warn_missing_arch=${2:-true} + # Get architectures for current target binary + binary_archs="$(lipo -info "$binary" | rev | cut -d ':' -f1 | awk '{$1=$1;print}' | rev)" + # Intersect them with the architectures we are building for + intersected_archs="$(echo ${ARCHS[@]} ${binary_archs[@]} | tr ' ' '\n' | sort | uniq -d)" + # If there are no archs supported by this binary then warn the user + if [[ -z "$intersected_archs" ]]; then + if [[ "$warn_missing_arch" == "true" ]]; then + echo "warning: [CP] Vendored binary '$binary' contains architectures ($binary_archs) none of which match the current build architectures ($ARCHS)." + fi + STRIP_BINARY_RETVAL=1 + return + fi + stripped="" + for arch in $binary_archs; do + if ! [[ "${ARCHS}" == *"$arch"* ]]; then + # Strip non-valid architectures in-place + lipo -remove "$arch" -output "$binary" "$binary" + stripped="$stripped $arch" + fi + done + if [[ "$stripped" ]]; then + echo "Stripped $binary of architectures:$stripped" + fi + STRIP_BINARY_RETVAL=0 +} + +# Copies the bcsymbolmap files of a vendored framework +install_bcsymbolmap() { + local bcsymbolmap_path="$1" + local destination="${BUILT_PRODUCTS_DIR}" + echo "rsync --delete -av "${RSYNC_PROTECT_TMP_FILES[@]}" --filter "- CVS/" --filter "- .svn/" --filter "- .git/" --filter "- .hg/" --filter "- Headers" --filter "- PrivateHeaders" --filter "- Modules" "${bcsymbolmap_path}" "${destination}"" + rsync --delete -av "${RSYNC_PROTECT_TMP_FILES[@]}" --filter "- CVS/" --filter "- .svn/" --filter "- .git/" --filter "- .hg/" --filter "- Headers" --filter "- PrivateHeaders" --filter "- Modules" "${bcsymbolmap_path}" "${destination}" +} + +# Signs a framework with the provided identity +code_sign_if_enabled() { + if [ -n "${EXPANDED_CODE_SIGN_IDENTITY:-}" -a "${CODE_SIGNING_REQUIRED:-}" != "NO" -a "${CODE_SIGNING_ALLOWED}" != "NO" ]; then + # Use the current code_sign_identity + echo "Code Signing $1 with Identity ${EXPANDED_CODE_SIGN_IDENTITY_NAME}" + local code_sign_cmd="/usr/bin/codesign --force --sign ${EXPANDED_CODE_SIGN_IDENTITY} ${OTHER_CODE_SIGN_FLAGS:-} --preserve-metadata=identifier,entitlements '$1'" + + if [ "${COCOAPODS_PARALLEL_CODE_SIGN}" == "true" ]; then + code_sign_cmd="$code_sign_cmd &" + fi + echo "$code_sign_cmd" + eval "$code_sign_cmd" + fi +} + +if [[ "$CONFIGURATION" == "Debug" ]]; then + install_framework "${BUILT_PRODUCTS_DIR}/coinlib_flutter/secp256k1.framework" + install_framework "${PODS_ROOT}/../Flutter/ephemeral/.symlinks/plugins/cs_monero_flutter_libs_macos/macos/Frameworks/MoneroWallet.framework" + install_framework "${BUILT_PRODUCTS_DIR}/cs_monero_flutter_libs_macos/cs_monero_flutter_libs_macos.framework" + install_framework "${PODS_ROOT}/../Flutter/ephemeral/.symlinks/plugins/cs_salvium_flutter_libs_macos/macos/Frameworks/SalviumWallet.framework" + install_framework "${BUILT_PRODUCTS_DIR}/cs_salvium_flutter_libs_macos/cs_salvium_flutter_libs_macos.framework" + install_framework "${PODS_ROOT}/../Flutter/ephemeral/.symlinks/plugins/cs_wownero_flutter_libs_macos/macos/Frameworks/WowneroWallet.framework" + install_framework "${BUILT_PRODUCTS_DIR}/cs_wownero_flutter_libs_macos/cs_wownero_flutter_libs_macos.framework" + install_framework "${PODS_ROOT}/../Flutter/ephemeral/.symlinks/plugins/isar_community_flutter_libs/macos/libisar.dylib" + install_framework "${BUILT_PRODUCTS_DIR}/isar_community_flutter_libs/isar_community_flutter_libs.framework" + install_framework "${BUILT_PRODUCTS_DIR}/window_size/window_size.framework" +fi +if [[ "$CONFIGURATION" == "Profile" ]]; then + install_framework "${BUILT_PRODUCTS_DIR}/coinlib_flutter/secp256k1.framework" + install_framework "${PODS_ROOT}/../Flutter/ephemeral/.symlinks/plugins/cs_monero_flutter_libs_macos/macos/Frameworks/MoneroWallet.framework" + install_framework "${BUILT_PRODUCTS_DIR}/cs_monero_flutter_libs_macos/cs_monero_flutter_libs_macos.framework" + install_framework "${PODS_ROOT}/../Flutter/ephemeral/.symlinks/plugins/cs_salvium_flutter_libs_macos/macos/Frameworks/SalviumWallet.framework" + install_framework "${BUILT_PRODUCTS_DIR}/cs_salvium_flutter_libs_macos/cs_salvium_flutter_libs_macos.framework" + install_framework "${PODS_ROOT}/../Flutter/ephemeral/.symlinks/plugins/cs_wownero_flutter_libs_macos/macos/Frameworks/WowneroWallet.framework" + install_framework "${BUILT_PRODUCTS_DIR}/cs_wownero_flutter_libs_macos/cs_wownero_flutter_libs_macos.framework" + install_framework "${PODS_ROOT}/../Flutter/ephemeral/.symlinks/plugins/isar_community_flutter_libs/macos/libisar.dylib" + install_framework "${BUILT_PRODUCTS_DIR}/isar_community_flutter_libs/isar_community_flutter_libs.framework" + install_framework "${BUILT_PRODUCTS_DIR}/window_size/window_size.framework" +fi +if [[ "$CONFIGURATION" == "Release" ]]; then + install_framework "${BUILT_PRODUCTS_DIR}/coinlib_flutter/secp256k1.framework" + install_framework "${PODS_ROOT}/../Flutter/ephemeral/.symlinks/plugins/cs_monero_flutter_libs_macos/macos/Frameworks/MoneroWallet.framework" + install_framework "${BUILT_PRODUCTS_DIR}/cs_monero_flutter_libs_macos/cs_monero_flutter_libs_macos.framework" + install_framework "${PODS_ROOT}/../Flutter/ephemeral/.symlinks/plugins/cs_salvium_flutter_libs_macos/macos/Frameworks/SalviumWallet.framework" + install_framework "${BUILT_PRODUCTS_DIR}/cs_salvium_flutter_libs_macos/cs_salvium_flutter_libs_macos.framework" + install_framework "${PODS_ROOT}/../Flutter/ephemeral/.symlinks/plugins/cs_wownero_flutter_libs_macos/macos/Frameworks/WowneroWallet.framework" + install_framework "${BUILT_PRODUCTS_DIR}/cs_wownero_flutter_libs_macos/cs_wownero_flutter_libs_macos.framework" + install_framework "${PODS_ROOT}/../Flutter/ephemeral/.symlinks/plugins/isar_community_flutter_libs/macos/libisar.dylib" + install_framework "${BUILT_PRODUCTS_DIR}/isar_community_flutter_libs/isar_community_flutter_libs.framework" + install_framework "${BUILT_PRODUCTS_DIR}/window_size/window_size.framework" +fi +if [ "${COCOAPODS_PARALLEL_CODE_SIGN}" == "true" ]; then + wait +fi diff --git a/scripts/macos/test/normalize_frameworks_test.rb b/scripts/macos/test/normalize_frameworks_test.rb new file mode 100644 index 0000000000..04fd257055 --- /dev/null +++ b/scripts/macos/test/normalize_frameworks_test.rb @@ -0,0 +1,175 @@ +require 'minitest/autorun' +require 'tmpdir' +require 'open3' +require_relative '../normalize_frameworks' + +class NormalizeFrameworksTest < Minitest::Test + def setup + @tmp = Dir.mktmpdir('framework-packaging-test') + @root = Pathname.new(@tmp) / 'stage' + @root.mkdir + @framework = @root / 'MoneroWallet.framework' + @version = @framework / 'Versions/A' + FileUtils.mkdir_p(@version / 'Resources') + (@version / 'MoneroWallet').write('binary') + (@version / 'Resources/Info.plist').write('metadata') + FileUtils.cp_r(@version, @framework / 'Versions/Current') + FileUtils.cp(@version / 'MoneroWallet', @framework / 'MoneroWallet') + FileUtils.cp_r(@version / 'Resources', @framework / 'Resources') + end + + def teardown + FileUtils.remove_entry(@tmp) + end + + def normalize + StackFrameworkPackaging.normalize(@root, @framework) + end + + def assert_unmodified_after_failure + before = StackFrameworkPackaging.inventory(@framework) + assert_raises(RuntimeError) { normalize } + assert_equal before, StackFrameworkPackaging.inventory(@framework) + end + + def test_identical_copies_become_links_and_repeated_runs_preserve_signed_content + normalize + assert_equal 'A', (@framework / 'Versions/Current').readlink.to_s + assert_equal 'Versions/Current/MoneroWallet', (@framework / 'MoneroWallet').readlink.to_s + assert_equal 'Versions/Current/Resources', (@framework / 'Resources').readlink.to_s + FileUtils.mkdir_p(@version / '_CodeSignature') + (@version / '_CodeSignature/CodeResources').write('signed resource seal') + normalize + assert_equal 'signed resource seal', (@framework / 'Versions/Current/_CodeSignature/CodeResources').read + end + + def test_binary_mismatch_does_not_partially_repair_directories + (@framework / 'MoneroWallet').write('different') + assert_unmodified_after_failure + end + + def test_resource_mismatch_does_not_partially_repair_any_entry + (@framework / 'Resources/Info.plist').write('different') + assert_unmodified_after_failure + end + + def test_current_extra_signature_is_not_discarded + (@framework / 'Versions/Current/extra-signature').write('stale') + assert_unmodified_after_failure + end + + def test_extra_top_level_entry_is_rejected + (@framework / 'unexpected').write('extra') + assert_unmodified_after_failure + end + + def test_nested_symlink_is_rejected_before_mutation + (@version / 'Resources/escape').make_symlink(@root) + assert_raises(RuntimeError) { normalize } + refute (@framework / 'Versions/Current').symlink? + end + + def test_unexpected_main_symlink_is_rejected + FileUtils.remove_entry(@framework / 'Resources') + (@framework / 'Resources').make_symlink(@root) + assert_raises(RuntimeError) { normalize } + refute (@framework / 'Versions/Current').symlink? + end + + def test_source_outside_staging_is_rejected + other = Pathname.new(@tmp) / 'other' + other.mkdir + assert_raises(RuntimeError) { StackFrameworkPackaging.normalize(other, @framework) } + refute (@framework / 'Versions/Current').symlink? + end + + def test_unknown_framework_is_untouched + other = @root / 'Other.framework' + FileUtils.mv(@framework, other) + before = StackFrameworkPackaging.inventory(other) + StackFrameworkPackaging.normalize(@root, other) + assert_equal before, StackFrameworkPackaging.inventory(other) + end + + def test_embed_patch_is_idempotent_and_forces_synchronous_signing + path = Pathname.new(@tmp) / 'embed.sh' + path.write("#{StackFrameworkPackaging::PARALLEL}\ninstall_framework()\n{\n rsync --delete -av source dest\n#{StackFrameworkPackaging::INSERTION}" + + ' code_sign_if_enabled "${destination}/$(basename "$1")"' + "\n}\n") + StackFrameworkPackaging.patch_embed_script(path) + once = path.read + StackFrameworkPackaging.patch_embed_script(path) + assert_equal once, path.read + assert_includes once, 'COCOAPODS_PARALLEL_CODE_SIGN=false' + assert_operator once.index(' normalize '), :<, once.index(' local basename') + end + + def test_actual_cocoapods_script_with_dsym_and_other_rsync_calls + path = Pathname.new(@tmp) / 'embed.sh' + fixture = Pathname.new(__dir__) / 'fixtures/cocoapods-frameworks.sh' + path.write(fixture.read) + StackFrameworkPackaging.patch_embed_script(path) + once = path.read + StackFrameworkPackaging.patch_embed_script(path) + assert_equal once, path.read + assert_equal 1, once.scan(' normalize ').length + assert_includes once, 'codesign --force --sign ${EXPANDED_CODE_SIGN_IDENTITY}' + assert_operator once.index(' normalize '), :<, once.index(' strip_invalid_archs "$binary"') + assert_operator once.index(' normalize '), :>, once.index(' rsync --delete -av ') + assert system('/bin/bash', '-n', path.to_s) + end + + def test_altered_previous_patch_fails_without_editing + path = Pathname.new(@tmp) / 'embed.sh' + path.write((Pathname.new(__dir__) / 'fixtures/cocoapods-frameworks.sh').read) + StackFrameworkPackaging.patch_embed_script(path) + path.write(path.read.sub('COCOAPODS_PARALLEL_CODE_SIGN=false', 'COCOAPODS_PARALLEL_CODE_SIGN=true')) + before = path.read + assert_raises(RuntimeError) { StackFrameworkPackaging.patch_embed_script(path) } + assert_equal before, path.read + end + + def test_failed_signing_stops_script_and_preserves_identity_and_disabled_signing + path = Pathname.new(@tmp) / 'embed.sh' + path.write((Pathname.new(__dir__) / 'fixtures/cocoapods-frameworks.sh').read) + StackFrameworkPackaging.patch_embed_script(path) + signer = Pathname.new(@tmp) / 'fake-codesign' + args = Pathname.new(@tmp) / 'signer-args' + reached = Pathname.new(@tmp) / 'reached' + signer.write("#!/bin/sh\nprintf '%s\\n' \"$@\" > #{Shellwords.escape(args.to_s)}\nexit 42\n") + signer.chmod(0755) + path.write(path.read.sub('/usr/bin/codesign', signer.to_s) + + "\ncode_sign_if_enabled '#{@root}/Other.framework'\ntouch '#{reached}'\n") + env = { + 'FRAMEWORKS_FOLDER_PATH' => 'Frameworks', 'CONFIGURATION_BUILD_DIR' => @tmp, + 'TOOLCHAIN_DIR' => @tmp, 'PLATFORM_NAME' => 'macosx', 'CONFIGURATION' => 'Test', + 'EXPANDED_CODE_SIGN_IDENTITY' => 'configured-identity', + 'EXPANDED_CODE_SIGN_IDENTITY_NAME' => 'Configured Identity', + 'CODE_SIGNING_REQUIRED' => 'YES', 'CODE_SIGNING_ALLOWED' => 'YES', + 'COCOAPODS_PARALLEL_CODE_SIGN' => 'true', + } + _out, _err, status = Open3.capture3(env, '/bin/bash', path.to_s) + assert_equal 42, status.exitstatus + refute reached.exist? + assert_equal ['--force', '--sign', 'configured-identity', '--preserve-metadata=identifier,entitlements', "#{@root}/Other.framework"], args.read.lines.map(&:chomp) + args.unlink + [ + { 'CODE_SIGNING_ALLOWED' => 'NO' }, + { 'CODE_SIGNING_REQUIRED' => 'NO' }, + { 'EXPANDED_CODE_SIGN_IDENTITY' => '' }, + ].each do |disabled| + _out, _err, status = Open3.capture3(env.merge(disabled), '/bin/bash', path.to_s) + assert status.success? + assert reached.exist? + refute args.exist? + reached.unlink + end + end + + def test_changed_embed_script_fails_without_editing + path = Pathname.new(@tmp) / 'embed.sh' + path.write("#{StackFrameworkPackaging::PARALLEL}\nunknown new copy routine\n") + before = path.read + assert_raises(RuntimeError) { StackFrameworkPackaging.patch_embed_script(path) } + assert_equal before, path.read + end +end From 2852ea73de02695c687bc29bac88777283880279 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 9 Oct 2026 16:44:14 -0500 Subject: [PATCH 29/32] test(macos): verify bundled deployment targets --- scripts/dev/verify_macos_deployment.py | 116 +++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100755 scripts/dev/verify_macos_deployment.py diff --git a/scripts/dev/verify_macos_deployment.py b/scripts/dev/verify_macos_deployment.py new file mode 100755 index 0000000000..b816e7963d --- /dev/null +++ b/scripts/dev/verify_macos_deployment.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Check a built app's advertised macOS floor against every embedded Mach-O slice. + +Usage: python3 scripts/dev/verify_macos_deployment.py path/to/Wallet.app +Read-only. Exit 0 means declared deployment targets fit, 1 means a mismatch, and 2 means +verification could not complete. Requires the Xcode command-line tools. +""" + +import argparse +import plistlib +import re +import subprocess +import sys +from pathlib import Path + +MACHO_MAGIC = { + bytes.fromhex(value) + for value in ( + "feedface", "cefaedfe", "feedfacf", "cffaedfe", + "cafebabe", "bebafeca", "cafebabf", "bfbafeca", + ) +} + + +def version(value): + if not isinstance(value, str) or not re.fullmatch(r"\d+(?:\.\d+){0,2}", value): + raise ValueError(f"Invalid macOS version: {value!r}") + parts = tuple(map(int, value.split("."))) + return parts + (0,) * (3 - len(parts)) + + +def run_tool(*args): + result = subprocess.run( + ["xcrun", *args], check=True, capture_output=True, text=True, timeout=30, + ) + return result.stdout + + +def minimum_version(binary, architecture): + output = run_tool("vtool", "-arch", architecture, "-show-build", str(binary)) + command = None + platform = None + found = [] + for line in output.splitlines(): + fields = line.split() + if len(fields) != 2: + continue + key, value = fields + if key == "cmd": + command, platform = value, None + elif key == "platform": + platform = value + elif command == "LC_BUILD_VERSION" and key == "minos": + if platform not in ("MACOS", "1"): + raise ValueError(f"{binary} [{architecture}] targets {platform}, not macOS") + found.append(value) + elif command == "LC_VERSION_MIN_MACOSX" and key == "version": + found.append(value) + if len(found) != 1: + raise ValueError(f"{binary} [{architecture}]: expected one macOS deployment load command") + return found[0] + + +def verify(app): + contents = app / "Contents" + with (contents / "Info.plist").open("rb") as stream: + advertised = plistlib.load(stream).get("LSMinimumSystemVersion") + floor = version(advertised) + seen = set() + slices = 0 + failures = [] + for entry in sorted(contents.rglob("*")): + if not entry.is_file(): + continue + resolved = entry.resolve() + if resolved in seen: + continue + seen.add(resolved) + with entry.open("rb") as stream: + if stream.read(4) not in MACHO_MAGIC: + continue + architectures = run_tool("lipo", "-archs", str(entry)).split() + if not architectures: + raise ValueError(f"No Mach-O architectures found: {entry}") + for architecture in architectures: + minimum = minimum_version(entry, architecture) + slices += 1 + if version(minimum) > floor: + failures.append( + f"{entry.relative_to(contents)} [{architecture}] requires macOS {minimum}" + ) + if not slices: + raise ValueError("No embedded Mach-O slices found; cannot verify this app") + print(f"App advertises macOS {advertised}; inspected {slices} Mach-O slices.") + for failure in failures: + print(f"FAIL: {failure}") + if failures: + print(f"Deployment mismatch: {len(failures)} slices require a newer OS.") + return 1 + print("PASS: embedded Mach-O deployment targets do not exceed the advertised minimum.") + return 0 + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("app", type=Path) + args = parser.parse_args() + try: + return verify(args.app) + except (OSError, ValueError, plistlib.InvalidFileException, subprocess.SubprocessError) as error: + print(f"Verification incomplete: {error}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) From 91b1519810932d64eee6a824b42fd30906baf4d7 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 9 Oct 2026 17:25:48 -0500 Subject: [PATCH 30/32] chore(deps): track the OpenAlias stack on GitHub Use Cypherstack GitHub branches for the resolver packages and their SOCKS 2 consumers. Consolidate the earlier version, private-remote, commit-pin, and review-branch transitions into this single dependency update. --- pubspec.lock | 49 ++++++++++++++----- .../templates/pubspec.template.yaml | 33 +++++++++---- 2 files changed, 61 insertions(+), 21 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index e4b5c83416..743eb47d01 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -829,6 +829,24 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + dnssec_resolver: + dependency: "direct main" + description: + path: "." + ref: main + resolved-ref: "5491d174bcfaa076ec6b49bcb37717a9287eb79a" + url: "https://github.com/cypherstack/dnssec_resolver.git" + source: git + version: "0.0.1" + doh_resolver: + dependency: "direct main" + description: + path: "." + ref: main + resolved-ref: e1d060d0c287f2bd00485d74848d1624ee6b5b00 + url: "https://github.com/cypherstack/doh_resolver.git" + source: git + version: "0.0.1" drift: dependency: "direct main" description: @@ -889,11 +907,11 @@ packages: dependency: "direct main" description: path: "." - ref: b6fa44d015d3bfa06934b73219928c29ca48a290 - resolved-ref: b6fa44d015d3bfa06934b73219928c29ca48a290 + ref: main + resolved-ref: "76ffad6ecc1e31de47d9187fd39c831db0aa987f" url: "https://github.com/cypherstack/electrum_adapter.git" source: git - version: "3.0.2" + version: "4.0.1" emojis: dependency: "direct main" description: @@ -1232,8 +1250,8 @@ packages: dependency: "direct main" description: path: "." - ref: "14427bcbbe1e754bce4a1b93cdb0a31ce56d792b" - resolved-ref: "14427bcbbe1e754bce4a1b93cdb0a31ce56d792b" + ref: main + resolved-ref: "7548bf1c6e79a82e991c3feb61c1908dfe324275" url: "https://github.com/cypherstack/fusiondart.git" source: git version: "1.0.0" @@ -1750,6 +1768,15 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.0" + openalias: + dependency: "direct main" + description: + path: "." + ref: main + resolved-ref: b6af74eda9e5a627381bdfd0e96a604409b0b95e + url: "https://github.com/cypherstack/openalias.git" + source: git + version: "0.0.1" package_config: dependency: transitive description: @@ -2140,13 +2167,13 @@ packages: source: hosted version: "2.1.1" socks_socket: - dependency: transitive + dependency: "direct main" description: name: socks_socket - sha256: "53bc7eae40a3aa16ea810b0e9de3bb23ba7beb0b40d09357b89190f2f44374cc" + sha256: "09149ed1278584554aeacc60c7aeec5bd80154e6ce1136f00d97485d09d5a338" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "2.0.0" solana: dependency: "direct main" description: @@ -2378,11 +2405,11 @@ packages: dependency: "direct main" description: path: "." - ref: "5586bfa3c48e495bc00f9b56fa18869daf768573" - resolved-ref: "5586bfa3c48e495bc00f9b56fa18869daf768573" + ref: "feat/bump-arti" + resolved-ref: "23ac35b417e38becf4189d90da29414ed2318824" url: "https://github.com/cypherstack/tor.git" source: git - version: "0.1.0" + version: "0.2.0" tuple: dependency: "direct main" description: diff --git a/scripts/app_config/templates/pubspec.template.yaml b/scripts/app_config/templates/pubspec.template.yaml index 3d7d56636f..4263b3b371 100644 --- a/scripts/app_config/templates/pubspec.template.yaml +++ b/scripts/app_config/templates/pubspec.template.yaml @@ -68,7 +68,7 @@ dependencies: # tor_ffi_plugin: # git: # url: https://github.com/cypherstack/tor.git -# ref: 5586bfa3c48e495bc00f9b56fa18869daf768573 +# ref: feat/bump-arti # %%END_ENABLE_TOR%% # %%ENABLE_XMR%% @@ -117,14 +117,14 @@ dependencies: fusiondart: git: url: https://github.com/cypherstack/fusiondart.git - ref: 14427bcbbe1e754bce4a1b93cdb0a31ce56d792b + ref: main # Utility plugins http: ^1.6.0 - openalias: ^0.2.0 - doh_resolver: ^0.3.0 - dnssec_resolver: ^0.1.0 - socks_socket: ^1.4.0 + openalias: ^0.0.1 + doh_resolver: ^0.0.1 + dnssec_resolver: ^0.0.1 + socks_socket: ^2.0.0 local_auth: ^2.3.0 permission_handler: ^13.0.1 flutter_local_notifications: ^22.3.0 @@ -233,7 +233,7 @@ dependencies: electrum_adapter: git: url: https://github.com/cypherstack/electrum_adapter.git - ref: b6fa44d015d3bfa06934b73219928c29ca48a290 + ref: main stream_channel: ^2.1.0 solana: git: # TODO [prio=low]: Revert to official package once Tor support is merged upstream. @@ -312,6 +312,20 @@ flutter_native_splash: color_dark: "2A2D34" dependency_overrides: + # Track the package review branches until the initial releases are published. + doh_resolver: + git: + url: https://github.com/cypherstack/doh_resolver.git + ref: main + dnssec_resolver: + git: + url: https://github.com/cypherstack/dnssec_resolver.git + ref: main + openalias: + git: + url: https://github.com/cypherstack/openalias.git + ref: main + # required for: native_toolchain_rust >=1.0.3 depends on toml ^0.18.0 # stellar sdk package needs to get 0.18.0 to remove this override toml: ^0.18.0 @@ -378,9 +392,8 @@ dependency_overrides: #hooks: # user_defines: # tor_ffi_plugin: -# native_build: prebuilt -# prebuilt_manifest_url: "https://github.com/cypherstack/tor/releases/download/native-0.1.0/manifest.json" -# prebuilt_manifest_sha256: "00a988a2c0994dff300bb19493e935b0d4eae2b506d02af9dd69ddad65805a24" +# # Use source builds until prebuilts match the review branch. +# native_build: source # flutter_libepiccash: # native_build: prebuilt # prebuilt_manifest_url: "https://github.com/cypherstack/flutter_libepiccash/releases/download/native-0.3.0/manifest.json" From 2f306604ef68741151d4c90a4a3404bd23076a20 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 9 Oct 2026 17:42:11 -0500 Subject: [PATCH 31/32] fix(openalias): reject hidden alias characters before normalization --- lib/services/openalias/open_alias.dart | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/services/openalias/open_alias.dart b/lib/services/openalias/open_alias.dart index ad25535a72..2942c39a4f 100644 --- a/lib/services/openalias/open_alias.dart +++ b/lib/services/openalias/open_alias.dart @@ -40,6 +40,14 @@ class OpenAliasException implements Exception { String normalizeOpenAlias(String input) { try { + // Validate the original input before the package can normalize it. Unicode + // whitespace and format controls must not silently change alias identity. + if (input.codeUnits.any((unit) => unit > 0x7f)) { + throw const oa.OpenAliasException( + oa.OpenAliasError.invalidName, + 'Use punycode for international domain names.', + ); + } return oa.normalizeOpenAlias(input); } on oa.OpenAliasException catch (error) { throw OpenAliasException(error.message); From 952561e8f8dc81a961c5526f0a53bb8aaf0600e4 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 9 Oct 2026 17:42:11 -0500 Subject: [PATCH 32/32] test(openalias): require authenticated preview provenance --- test/services/openalias/send_preview_test.dart | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/test/services/openalias/send_preview_test.dart b/test/services/openalias/send_preview_test.dart index 58d2005ef6..effde3207b 100644 --- a/test/services/openalias/send_preview_test.dart +++ b/test/services/openalias/send_preview_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; +import 'package:openalias/openalias.dart' as oa; import 'package:stackwallet/services/openalias/open_alias.dart'; import 'package:stackwallet/services/openalias/send_preview.dart'; @@ -74,12 +75,18 @@ void main() { final dns = authenticatedTxt('alice.example', [ 'oa1:xmr recipient_address=literal;', ]); + final resolved = await oa.OpenAliasResolver( + lookup: (_) async => dns, + application: 'xmr', + validateAddress: (address) => address == 'literal', + trustedValidators: testValidators, + ).resolve('alice.example'); await run( pending: Future.value( OpenAliasRecipient( domain: 'alice.example', address: 'literal', - dns: dns, + resolved: resolved, ), ), );