feat: add support for Zcash names resolution (#3237)
What changed, and why it matters
This commit adds a new feature that lets users type human-friendly Zcash names (like 'alice.zec' or 'bob.zcash') instead of long wallet addresses. The app looks up the real address from a remote service called zcashnames.com. The change also includes a few small swap-screen fixes and a safety catch around another name lookup. There is no clear security bug in the diff, but any name-to-address lookup adds a small risk that a malicious or compromised server could redirect payments.
Treat this as a routine feature addition with no immediate security patch needed. As a defense-in-depth step, the project should confirm that main.zcashnames.com is a trusted, documented Zcash Names resolver, consider pinning its certificate or supporting a user-configurable endpoint, and ensure the address-validation regex covers all supported Zcash address formats (including unified addresses if applicable).
Security signals we found
New network lookup of payment addresses from a single third-party endpoint (main.zcashnames.com)
Returned address is validated against a local Zcash address regex before use
Lookup is gated by a user preference that defaults to enabled
No certificate pinning or additional authentication for the lookup endpoint visible in the diff
Minor hardening: ThorChain name lookup now catches exceptions instead of crashing
Evidence from the diff
The patch introduces ZcashNamesRecord, which sends a JSON-RPC ‘resolve’ request to https://main.zcashnames.com for inputs ending in .zec or .zcash. It validates the returned address against the existing Zcash address pattern before returning it. A new settings toggle (defaulting to true) controls the feature, and the resolver is wired into the existing AddressResolver alongside ENS, OpenAlias, etc. The commit also wraps ThorChainExchangeProvider.lookupAddressByName in a try/catch and makes minor UI fixes for swap memos and destination-tag overflow.
Changed components
lib/entities/parse_address_from_domain.dartlib/entities/parsed_address.dartlib/entities/preferences_key.dartlib/entities/zcash_names_record.dartlib/exchange/provider/thorchain_exchange.provider.dartlib/src/screens/send/widgets/extract_address_from_parsed.dartlib/src/screens/settings/domain_lookups_page.dartlib/store/settings_store.dartlib/view_model/settings/connection_sync_view_model.dartlib/view_model/settings/privacy_settings_view_model.dartInspect captured patch +156 / −23
diff --git a/lib/entities/parse_address_from_domain.dart b/lib/entities/parse_address_from_domain.dart
index 36fc3d6..0eea5a8 100644
--- a/lib/entities/parse_address_from_domain.dart
+++ b/lib/entities/parse_address_from_domain.dart
@@ -9,6 +9,7 @@ import 'package:cake_wallet/entities/parsed_address.dart';
import 'package:cake_wallet/entities/unstoppable_domain_address.dart';
import 'package:cake_wallet/entities/wellknown_record.dart';
import 'package:cake_wallet/entities/zano_alias.dart';
+import 'package:cake_wallet/entities/zcash_names_record.dart';
import 'package:cake_wallet/exchange/provider/thorchain_exchange.provider.dart';
import 'package:cake_wallet/mastodon/mastodon_api.dart';
import 'package:cake_wallet/nostr/nostr_api.dart';
@@ -458,6 +459,16 @@ class AddressResolver {
}
}
+ final lowerText = text.toLowerCase();
+ if (lowerText.endsWith(".zec") || lowerText.endsWith(".zcash")) {
+ if (settingsStore.lookupsZcashNames) {
+ final address = await ZcashNamesRecord.fetchZcashNamesAddress(text);
+ if (address != null && address.isNotEmpty) {
+ return ParsedAddress.zcashNameAddress(address: address, name: text);
+ }
+ }
+ }
+
if (text.endsWith(".eth")) {
if (settingsStore.lookupsENS) {
final address = await EnsRecord.fetchEnsAddress(text, wallet: wallet);
diff --git a/lib/entities/parsed_address.dart b/lib/entities/parsed_address.dart
index 329e835..28bdf70 100644
--- a/lib/entities/parsed_address.dart
+++ b/lib/entities/parsed_address.dart
@@ -16,6 +16,7 @@ enum ParseFrom {
wellKnown,
zanoAlias,
zcashAddress,
+ zcashName,
bip353,
lnurlpay,
}
@@ -177,6 +178,14 @@ class ParsedAddress {
);
}
+ factory ParsedAddress.zcashNameAddress({required String address, required String name}) {
+ return ParsedAddress(
+ addresses: [address],
+ name: name,
+ parseFrom: ParseFrom.zcashName,
+ );
+ }
+
factory ParsedAddress.fetchWellKnownAddress({required String address, required String name}) {
return ParsedAddress(
addresses: [address],
diff --git a/lib/entities/preferences_key.dart b/lib/entities/preferences_key.dart
index 9e30e2e..be90ed6 100644
--- a/lib/entities/preferences_key.dart
+++ b/lib/entities/preferences_key.dart
@@ -101,6 +101,7 @@ class PreferencesKey {
static const lookupsUnstoppableDomains = 'looks_up_unstoppable_domain';
static const lookupsOpenAlias = 'looks_up_open_alias';
static const lookupsENS = 'looks_up_ens';
+ static const lookupsZcashNames = 'looks_up_zcash_names';
static const lookupsWellKnown = 'looks_up_well_known';
static const useBlinkProtection = 'use_blink_protection';
static const usePayjoin = 'use_payjoin';
diff --git a/lib/entities/zcash_names_record.dart b/lib/entities/zcash_names_record.dart
new file mode 100644
index 0000000..1a20a47
--- /dev/null
+++ b/lib/entities/zcash_names_record.dart
@@ -0,0 +1,75 @@
+import 'dart:async';
+import 'dart:convert';
+
+import 'package:cake_wallet/core/address_validator.dart';
+import 'package:cw_core/crypto_currency.dart';
+import 'package:cw_core/utils/print_verbose.dart';
+import 'package:cw_core/utils/proxy_wrapper.dart';
+
+class ZcashNamesRecord {
+ static final Uri _mainnetEndpoint = Uri.parse('https://main.zcashnames.com');
+
+ static final RegExp _nameRegExp = RegExp(r'^[A-Za-z0-9_-]{1,63}$');
+
+ static Future<String?> fetchZcashNamesAddress(String input) async {
+ final name = _extractName(input);
+ if (name == null) return null;
+
+ try {
+ final response = await ProxyWrapper().post(
+ clearnetUri: _mainnetEndpoint,
+ headers: const {'Content-Type': 'application/json'},
+ body: jsonEncode({
+ 'jsonrpc': '2.0',
+ 'id': 1,
+ 'method': 'resolve',
+ 'params': {'query': name},
+ }),
+ );
+
+ if (response.statusCode != 200) {
+ printV('ZcashNames: non-200 status ${response.statusCode} for $name');
+ return null;
+ }
+
+ final decoded = jsonDecode(response.body);
+ if (decoded is! Map<String, dynamic>) return null;
+ if (decoded['error'] != null) return null;
+
+ final result = decoded['result'];
+ if (result is! Map<String, dynamic>) return null;
+
+ final address = result['address'];
+ if (address is! String || address.isEmpty) return null;
+
+ if (!_isValidZcashAddress(address)) {
+ printV('ZcashNames: returned address failed validation for $name');
+ return null;
+ }
+ return address;
+ } catch (e) {
+ printV('ZcashNames: lookup failed for $name: $e');
+ return null;
+ }
+ }
+
+ static String? _extractName(String input) {
+ final lower = input.toLowerCase().trim();
+ String? raw;
+ if (lower.endsWith('.zcash')) {
+ raw = lower.substring(0, lower.length - '.zcash'.length);
+ } else if (lower.endsWith('.zec')) {
+ raw = lower.substring(0, lower.length - '.zec'.length);
+ }
+ if (raw == null || raw.isEmpty) return null;
+ if (!_nameRegExp.hasMatch(raw)) return null;
+ return raw;
+ }
+
+ static bool _isValidZcashAddress(String address) {
+ final pattern =
+ AddressValidator.getAddressFromStringPattern(CryptoCurrency.zec);
+ if (pattern == null) return false;
+ return RegExp('^(?:$pattern)\$').hasMatch(address);
+ }
+}
diff --git a/lib/exchange/provider/thorchain_exchange.provider.dart b/lib/exchange/provider/thorchain_exchange.provider.dart
index 1d05a4f..e9b6237 100644
--- a/lib/exchange/provider/thorchain_exchange.provider.dart
+++ b/lib/exchange/provider/thorchain_exchange.provider.dart
@@ -256,28 +256,33 @@ class ThorChainExchangeProvider extends ExchangeProvider {
static Future<Map<String, String>?>? lookupAddressByName(String name) async {
final uri = Uri.https(_baseURL, '$_nameLookUpPath$name');
- final response = await ProxyWrapper().get(clearnetUri: uri);
-
- if (response.statusCode != 200) {
- return null;
- }
-
- final body = json.decode(response.body) as Map<String, dynamic>;
- final entries = body['entries'] as List<dynamic>?;
-
- if (entries == null || entries.isEmpty) {
- return null;
- }
-
- Map<String, String> chainToAddressMap = {};
-
- for (final entry in entries) {
- final chain = entry['chain'] as String;
- final address = entry['address'] as String;
- chainToAddressMap[chain] = address;
- }
-
- return chainToAddressMap;
+ try {
+ final response = await ProxyWrapper().get(clearnetUri: uri);
+
+ if (response.statusCode != 200) {
+ return null;
+ }
+
+ final body = json.decode(response.body) as Map<String, dynamic>;
+ final entries = body['entries'] as List<dynamic>?;
+
+ if (entries == null || entries.isEmpty) {
+ return null;
+ }
+
+ Map<String, String> chainToAddressMap = {};
+
+ for (final entry in entries) {
+ final chain = entry['chain'] as String;
+ final address = entry['address'] as String;
+ chainToAddressMap[chain] = address;
+ }
+
+ return chainToAddressMap;
+} catch (e) {
+ printV(e.toString());
+ return null;
+}
}
Future<Map<String, dynamic>> _getSwapQuote(Map<String, String> params) async {
diff --git a/lib/src/screens/send/widgets/extract_address_from_parsed.dart b/lib/src/screens/send/widgets/extract_address_from_parsed.dart
index ae08d58..e2564d3 100644
--- a/lib/src/screens/send/widgets/extract_address_from_parsed.dart
+++ b/lib/src/screens/send/widgets/extract_address_from_parsed.dart
@@ -78,6 +78,11 @@ Future<String> extractAddressFromParsed(
content = S.of(context).extracted_address_content('${parsedAddress.name} (Zcash.me)');
address = parsedAddress.addresses.first;
break;
+ case ParseFrom.zcashName:
+ title = S.of(context).address_detected;
+ content = S.of(context).extracted_address_content('${parsedAddress.name} (Zcash Names)');
+ address = parsedAddress.addresses.first;
+ break;
case ParseFrom.bip353:
title = S.of(context).address_detected;
content = S.of(context).extracted_address_content('${parsedAddress.name} (BIP-353)');
diff --git a/lib/src/screens/settings/domain_lookups_page.dart b/lib/src/screens/settings/domain_lookups_page.dart
index b566892..9240af6 100644
--- a/lib/src/screens/settings/domain_lookups_page.dart
+++ b/lib/src/screens/settings/domain_lookups_page.dart
@@ -45,6 +45,11 @@ class DomainLookupsPage extends BasePage {
title: 'Ethereum Name Service',
value: _connectionsSyncViewModel.looksUpENS,
onValueChange: (_, bool value) => _connectionsSyncViewModel.setLookupsENS(value)),
+ SettingsSwitcherCell(
+ title: 'Zcash Names',
+ value: _connectionsSyncViewModel.lookupsZcashNames,
+ onValueChange: (_, bool value) =>
+ _connectionsSyncViewModel.setLookupsZcashNames(value)),
SettingsSwitcherCell(
title: '.well-known',
value: _connectionsSyncViewModel.looksUpWellKnown,
diff --git a/lib/store/settings_store.dart b/lib/store/settings_store.dart
index fb14bea..9551942 100644
--- a/lib/store/settings_store.dart
+++ b/lib/store/settings_store.dart
@@ -131,6 +131,7 @@ abstract class SettingsStoreBase with Store {
required this.lookupsUnstoppableDomains,
required this.lookupsOpenAlias,
required this.lookupsENS,
+ required this.lookupsZcashNames,
required this.lookupsWellKnown,
required this.usePayjoin,
required this.showPayjoinCard,
@@ -561,6 +562,11 @@ abstract class SettingsStoreBase with Store {
reaction((_) => lookupsENS,
(bool looksUpENS) => _sharedPreferences.setBool(PreferencesKey.lookupsENS, looksUpENS));
+ reaction(
+ (_) => lookupsZcashNames,
+ (bool looksUpZcashNames) => _sharedPreferences.setBool(
+ PreferencesKey.lookupsZcashNames, looksUpZcashNames));
+
reaction(
(_) => lookupsWellKnown,
(bool looksUpWellKnown) =>
@@ -953,6 +959,9 @@ abstract class SettingsStoreBase with Store {
@observable
bool lookupsENS;
+ @observable
+ bool lookupsZcashNames;
+
@observable
bool lookupsWellKnown;
@@ -1260,6 +1269,8 @@ abstract class SettingsStoreBase with Store {
sharedPreferences.getBool(PreferencesKey.lookupsUnstoppableDomains) ?? true;
final lookupsOpenAlias = sharedPreferences.getBool(PreferencesKey.lookupsOpenAlias) ?? true;
final lookupsENS = sharedPreferences.getBool(PreferencesKey.lookupsENS) ?? true;
+ final lookupsZcashNames =
+ sharedPreferences.getBool(PreferencesKey.lookupsZcashNames) ?? true;
final lookupsWellKnown = sharedPreferences.getBool(PreferencesKey.lookupsWellKnown) ?? true;
final usePayjoin = sharedPreferences.getBool(PreferencesKey.usePayjoin) ?? false;
final showPayjoinCard = sharedPreferences.getBool(PreferencesKey.showPayjoinCard) ?? true;
@@ -1625,6 +1636,7 @@ abstract class SettingsStoreBase with Store {
lookupsUnstoppableDomains: lookupsUnstoppableDomains,
lookupsOpenAlias: lookupsOpenAlias,
lookupsENS: lookupsENS,
+ lookupsZcashNames: lookupsZcashNames,
lookupsWellKnown: lookupsWellKnown,
usePayjoin: usePayjoin,
showPayjoinCard: showPayjoinCard,
@@ -1851,6 +1863,8 @@ abstract class SettingsStoreBase with Store {
sharedPreferences.getBool(PreferencesKey.lookupsUnstoppableDomains) ?? true;
lookupsOpenAlias = sharedPreferences.getBool(PreferencesKey.lookupsOpenAlias) ?? true;
lookupsENS = sharedPreferences.getBool(PreferencesKey.lookupsENS) ?? true;
+ lookupsZcashNames =
+ sharedPreferences.getBool(PreferencesKey.lookupsZcashNames) ?? true;
lookupsWellKnown = sharedPreferences.getBool(PreferencesKey.lookupsWellKnown) ?? true;
customBitcoinFeeRate = sharedPreferences.getInt(PreferencesKey.customBitcoinFeeRate) ?? 1;
silentPaymentsCardDisplay =
diff --git a/lib/view_model/settings/connection_sync_view_model.dart b/lib/view_model/settings/connection_sync_view_model.dart
index b48ae32..47f4be7 100644
--- a/lib/view_model/settings/connection_sync_view_model.dart
+++ b/lib/view_model/settings/connection_sync_view_model.dart
@@ -6,7 +6,6 @@ import 'package:cake_wallet/evm/evm.dart';
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/reactions/wallet_connect.dart';
import 'package:cake_wallet/src/widgets/alert_with_one_action.dart';
-import 'package:cake_wallet/store/app_store.dart';
import 'package:cake_wallet/store/settings_store.dart';
import 'package:cake_wallet/tron/tron.dart';
import 'package:cake_wallet/utils/show_pop_up.dart';
@@ -50,6 +49,9 @@ abstract class ConnectionSyncViewModelBase with Store {
@computed
bool get looksUpENS => _settingsStore.lookupsENS;
+ @computed
+ bool get lookupsZcashNames => _settingsStore.lookupsZcashNames;
+
@computed
bool get looksUpWellKnown => _settingsStore.lookupsWellKnown;
@@ -129,6 +131,9 @@ abstract class ConnectionSyncViewModelBase with Store {
@action
void setLookupsENS(bool value) => _settingsStore.lookupsENS = value;
+ @action
+ void setLookupsZcashNames(bool value) => _settingsStore.lookupsZcashNames = value;
+
@action
void setLookupsWellKnown(bool value) => _settingsStore.lookupsWellKnown = value;
diff --git a/lib/view_model/settings/privacy_settings_view_model.dart b/lib/view_model/settings/privacy_settings_view_model.dart
index c91937d..b920a21 100644
--- a/lib/view_model/settings/privacy_settings_view_model.dart
+++ b/lib/view_model/settings/privacy_settings_view_model.dart
@@ -99,6 +99,9 @@ abstract class PrivacySettingsViewModelBase with Store {
@computed
bool get looksUpENS => _settingsStore.lookupsENS;
+ @computed
+ bool get lookupsZcashNames => _settingsStore.lookupsZcashNames;
+
@computed
bool get looksUpWellKnown => _settingsStore.lookupsWellKnown;
Why this scored 20/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.