privacy: only domain-anchored address resolvers; drop Cake endpoints

Address-field input no longer leaves the device except toward
infrastructure implied by the typed name itself:
- remove Twitter, Mastodon, Nostr, Yat, Unstoppable Domains, FIO,
  ThorChain-name, zcash.me, and Zano alias lookups (Nostr and FIO ran
  with no setting gate at all; Zano is not even a supported chain)
- keep OpenAlias, BIP-353, .well-known, LNURL, and ENS, each toggleable
  in Settings -> Domain lookups
- BTC fee/block/restore-height queries move from mempool.cakewallet.com
  to public mempool.space (also fixes two /api/v1 paths that 404 on
  standard mempool deployments)
- PRIVACY.md updated to disclose the remaining flows (mempool.space,
  name lookups, opt-in silent payments and payjoin); issue templates and
  the translation prompt de-Caked
This commit is contained in:
jwinterm
2026-08-15 16:28:54 -04:00
parent 77ae91e695
commit 71706314fb
20 changed files with 48 additions and 1056 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ If applicable, add screenshots to help explain your problem.
**Platform:**
- OS: [e.g. iOS 15.1, Android 14]
- Device: [e.g. iPhone 14, Galaxy S21]
- Cake Wallet Version: [e.g. 4.12.1]
- Hash Bags Version: [e.g. 1.0.0]
**Additional context**
+4 -7
View File
@@ -1,11 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Feature or Enhancement Request ✨
url: https://github.com/cake-tech/cake_wallet/discussions/new?category=feature-requests
about: Suggest an idea for Cake Wallet
- name: Not sure where to start?
url: https://docs.cakewallet.com
about: Start by reading checking out the guides!
url: https://github.com/Such-Software/hash-wallet/discussions
about: Suggest an idea for Hash Bags
- name: Need help?
url: https://cakewallet.com/#contact
about: Use our live chat or send a support email!
url: https://hash.boats
about: Guides, support, and contact info
+22 -1
View File
@@ -1,6 +1,6 @@
# Privacy Policy
**Effective: July 2, 2026**
**Effective: August 15, 2026**
The canonical, always-current version of this policy is published at
**https://hash.boats/privacy**. This file mirrors it for the source repository;
@@ -68,6 +68,27 @@ phrase still restores the wallet elsewhere.
- **Price data.** The app fetches exchange rates from `prices.neroswap.com`, a
service we operate that aggregates public sources. It sees your IP and the
currencies you display — not your addresses or balances.
- **Bitcoin fee and block data.** For Bitcoin wallets, the app queries the
public `mempool.space` API for recommended fee rates and block timestamps.
It sees your IP and the block heights queried — not your addresses or
transactions. This is on by default and can be turned off in settings.
- **Name lookups.** If you type a human-readable name instead of an address,
the app can resolve it. Every resolver is limited to infrastructure implied
by the name itself, and each can be toggled in Settings → Domain lookups:
OpenAlias and BIP-353 query DNS; `.well-known` and Lightning-address
lookups contact the domain in the identifier you typed; ENS (`.eth` names)
queries the public Ethereum RPC at `ethereum-rpc.publicnode.com`. Hash Bags
does not send what you type to social networks or third-party identity
services.
- **Silent Payments scanning (opt-in, Bitcoin).** If you enable Silent
Payments, scanning uses a compatible Electrum server (the current default,
`electrs.cakewallet.com`, is operated by Cake Labs, a third party). That
server sees your IP and the block ranges you scan — not your keys. You can
point it at your own server in settings.
- **Payjoin (opt-in, Bitcoin, off by default).** If you enable Payjoin,
traffic is relayed through an oblivious-HTTP relay chosen from a small
public pool (currently operated by bobspacebkk.com, achow101.com, and
Cake Labs). The relay sees your IP but not the transaction contents.
- **Trocador (swaps).** When you start a swap, order details (input/output coin,
amount, receiving address) are sent to Trocador's API. Trocador is an
independent controller with its own policy at
+6 -6
View File
@@ -748,7 +748,7 @@ abstract class ElectrumWalletBase
if (await checkIfMempoolAPIIsEnabled() && type == WalletType.bitcoin) {
try {
final response = await ProxyWrapper()
.get(clearnetUri: Uri.parse("https://mempool.cakewallet.com/api/v1/fees/recommended"))
.get(clearnetUri: Uri.parse("https://mempool.space/api/v1/fees/recommended"))
.timeout(Duration(seconds: 15));
final result = json.decode(response.body) as Map<String, dynamic>;
@@ -2292,7 +2292,7 @@ abstract class ElectrumWalletBase
final blockHash = await ProxyWrapper()
.get(
clearnetUri: Uri.parse(
"https://mempool.cakewallet.com/api/v1/block-height/$height",
"https://mempool.space/api/block-height/$height",
),
)
.timeout(Duration(seconds: 15));
@@ -2303,7 +2303,7 @@ abstract class ElectrumWalletBase
final blockResponse = await ProxyWrapper()
.get(
clearnetUri: Uri.parse(
"https://mempool.cakewallet.com/api/v1/block/${blockHash.body}",
"https://mempool.space/api/block/${blockHash.body}",
),
)
.timeout(Duration(seconds: 15));
@@ -3137,7 +3137,7 @@ abstract class ElectrumWalletBase
final blockHashResp = await ProxyWrapper()
.get(
clearnetUri: Uri.parse(
'https://mempool.cakewallet.com/api/v1/block-height/$h',
'https://mempool.space/api/block-height/$h',
),
)
.timeout(const Duration(seconds: 15));
@@ -3150,7 +3150,7 @@ abstract class ElectrumWalletBase
final blockResp = await ProxyWrapper()
.get(
clearnetUri: Uri.parse(
'https://mempool.cakewallet.com/api/v1/block/$blockHash',
'https://mempool.space/api/block/$blockHash',
),
)
.timeout(const Duration(seconds: 15));
@@ -4086,7 +4086,7 @@ Future<void> _handleScanSilentPayments(ScanData scanData) async {
// So, if blockDate exists, reuse
if (isDateNow) {
try {
final rootURL = "https://cake.mempool.space";
final rootURL = "https://mempool.space";
final tweakBlockHash = await ProxyWrapper()
.get(clearnetUri: Uri.parse("$rootURL/api/block-height/$tweakHeight"))
.timeout(Duration(seconds: 15));
+1 -1
View File
@@ -127,7 +127,7 @@ Future<int> getBitcoinHeightByDateAPI({required DateTime date}) async {
final req = await ProxyWrapper()
.getHttpClient()
.getUrl(Uri.parse(
"https://mempool.cakewallet.com/api/v1/mining/blocks/timestamp/${(date.millisecondsSinceEpoch / 1000).round()}"))
"https://mempool.space/api/v1/mining/blocks/timestamp/${(date.millisecondsSinceEpoch / 1000).round()}"))
.timeout(Duration(seconds: 15));
final response = await req.close();
final stringResponse = await response.transform(utf8.decoder).join();
-55
View File
@@ -1,55 +0,0 @@
import 'dart:convert';
import 'package:hash_wallet/entities/yat_record.dart';
import 'package:cw_core/utils/proxy_wrapper.dart';
class YatService {
static bool isDevMode = false;
static String get apiUrl =>
YatService.isDevMode ? YatService.apiDevUrl : YatService.apiReleaseUrl;
static const apiReleaseUrl = "https://a.y.at";
static const apiDevUrl = 'https://a.yat.fyi';
static String lookupEmojiUrl(String emojiId) =>
"$apiUrl/emoji_id/$emojiId/payment";
static const String MONERO_SUB_ADDRESS = '0x1002';
static const String MONERO_STD_ADDRESS = '0x1001';
static const tags = {
'XMR': "$MONERO_STD_ADDRESS,$MONERO_SUB_ADDRESS",
'BTC': '0x1003',
'LTC': '0x1019'
};
Future<List<YatRecord>> fetchYatAddress(String emojiId, String ticker) async {
final formattedTicker = ticker.toUpperCase();
final formattedEmojiId = emojiId.replaceAll(' ', '');
final tag = tags[formattedTicker];
final uri = Uri.parse(lookupEmojiUrl(formattedEmojiId)).replace(
queryParameters: <String, dynamic>{
"tags": tag
});
final yatRecords = <YatRecord>[];
try {
final response = await ProxyWrapper().get(clearnetUri: uri);
final resBody = json.decode(response.body) as Map<String, dynamic>;
final results = resBody["result"] as Map<dynamic, dynamic>;
// Favour a subaddress over a standard address.
final yatRecord = (
results[MONERO_SUB_ADDRESS] ??
results[MONERO_STD_ADDRESS] ??
results[tag]) as Map<String, dynamic>;
if (yatRecord.isNotEmpty) {
yatRecords.add(YatRecord.fromJson(yatRecord));
}
return yatRecords;
} catch (_) {
return yatRecords;
}
}
}
-4
View File
@@ -29,7 +29,6 @@ import 'package:hash_wallet/core/totp_request_details.dart';
import 'package:hash_wallet/core/trade_monitor.dart';
import 'package:hash_wallet/core/wallet_creation_service.dart';
import 'package:hash_wallet/core/wallet_loading_service.dart';
import 'package:hash_wallet/core/yat_service.dart';
import 'package:hash_wallet/entities/biometric_auth.dart';
import 'package:hash_wallet/entities/bridge_transfer.dart';
import 'package:hash_wallet/entities/contact.dart';
@@ -1580,10 +1579,7 @@ Future<void> setup({
param1: item, param2: unspentCoinsListViewModel));
});
getIt.registerFactory(() => YatService());
getIt.registerFactory(() => AddressResolver(
yatService: getIt.get<YatService>(),
wallet: getIt.get<AppStore>().wallet!,
settingsStore: getIt.get<SettingsStore>()));
-26
View File
@@ -1,26 +0,0 @@
import 'package:flutter/material.dart';
extension Emoji on String {
static final REGEX_EMOJI = RegExp(
r'[\u{1f300}-\u{1f5ff}\u{1f900}-\u{1f9ff}\u{1f600}-\u{1f64f}'
r'\u{1f680}-\u{1f6ff}\u{2600}-\u{26ff}\u{2700}'
r'-\u{27bf}\u{1f1e6}-\u{1f1ff}\u{1f191}-\u{1f251}'
r'\u{1f004}\u{1f0cf}\u{1f170}-\u{1f171}\u{1f17e}'
r'-\u{1f17f}\u{1f18e}\u{3030}\u{2b50}\u{2b55}'
r'\u{2934}-\u{2935}\u{2b05}-\u{2b07}\u{2b1b}'
r'-\u{2b1c}\u{3297}\u{3299}\u{303d}\u{00a9}'
r'\u{00ae}\u{2122}\u{23f3}\u{24c2}\u{23e9}'
r'-\u{23ef}\u{25b6}\u{23f8}-\u{23fa}\u{200d}]+',
unicode: true,
);
bool _hasOnlyEmojis() {
final parsedText = this.replaceAll(' ', '');
if(parsedText.isEmpty) return false;
for (final c in Characters(parsedText))
if (!REGEX_EMOJI.hasMatch(c)) return false;
return true;
}
/// Returns true if the given text contains only emojis.
bool get hasOnlyEmojis => _hasOnlyEmojis();
}
-65
View File
@@ -1,65 +0,0 @@
import 'dart:convert';
import 'package:cw_core/utils/proxy_wrapper.dart';
class FioAddressProvider {
static const apiAuthority = 'fio.blockpane.com';
static const availCheck = '/v1/chain/avail_check';
static const getAddress = '/v1/chain/get_pub_address';
static Future<bool> checkAvail(String fioAddress) async {
bool isFioRegistered = false;
final headers = {'Content-Type': 'application/json'};
final body = <String, String>{"fio_name": fioAddress};
final uri = Uri.https(apiAuthority, availCheck);
final response = await ProxyWrapper().post(
clearnetUri: uri,
headers: headers,
body: json.encode(body),
);
if (response.statusCode != 200) {
return isFioRegistered;
}
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
isFioRegistered = responseJSON['is_registered'] as int == 1;
return isFioRegistered;
}
static Future<String> getPubAddress(String fioAddress, String token) async {
final headers = {'Content-Type': 'application/json'};
final body = <String, String>{
"fio_address": fioAddress,
"chain_code": token.toUpperCase(),
"token_code": token.toUpperCase(),
};
final uri = Uri.https(apiAuthority, getAddress);
final response = await ProxyWrapper().post(
clearnetUri: uri,
headers: headers,
body: json.encode(body),
);
if (response.statusCode == 400) {
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
final error = responseJSON['error'] as String;
final message = responseJSON['message'] as String;
throw Exception('${error}\n$message');
}
if (response.statusCode != 200) {
throw Exception('Unexpected response http status: ${response.statusCode}');
}
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
final String pubAddress = responseJSON['public_address'] as String;
return pubAddress;
}
}
+11 -400
View File
@@ -1,175 +1,35 @@
import 'package:hash_wallet/core/address_validator.dart';
import 'package:hash_wallet/core/yat_service.dart';
import 'package:hash_wallet/entities/emoji_string_extension.dart';
import 'package:hash_wallet/entities/ens_record.dart';
import 'package:hash_wallet/entities/lnurlpay_record.dart';
import 'package:hash_wallet/entities/fio_address_provider.dart';
import 'package:hash_wallet/entities/openalias_record.dart';
import 'package:hash_wallet/entities/parsed_address.dart';
import 'package:hash_wallet/entities/unstoppable_domain_address.dart';
import 'package:hash_wallet/entities/wellknown_record.dart';
import 'package:hash_wallet/entities/zano_alias.dart';
import 'package:hash_wallet/exchange/provider/thorchain_exchange.provider.dart';
import 'package:hash_wallet/mastodon/mastodon_api.dart';
import 'package:hash_wallet/nostr/nostr_api.dart';
import 'package:hash_wallet/store/settings_store.dart';
import 'package:hash_wallet/twitter/twitter_api.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/utils/proxy_wrapper.dart';
import 'package:cw_core/wallet_base.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:flutter/cupertino.dart';
import 'bip_353_record.dart';
/// Resolves human-readable names typed into the address field.
///
/// Privacy contract: every lookup here either queries DNS (OpenAlias,
/// BIP-353), the domain the user explicitly typed (.well-known, LNURL-pay),
/// or the wallet's own configured RPC node (ENS). Resolvers that shipped the
/// typed input to unrelated third-party services (Twitter, Mastodon, Nostr,
/// Yat, Unstoppable Domains, FIO, ThorChain names, zcash.me, Zano aliases)
/// were removed deliberately — do not reintroduce one without updating
/// PRIVACY.md and gating it behind an off-by-default setting.
class AddressResolver {
AddressResolver({required this.yatService, required this.wallet, required this.settingsStore})
AddressResolver({required this.wallet, required this.settingsStore})
: walletType = wallet.type;
final YatService yatService;
final WalletType walletType;
final WalletBase wallet;
final SettingsStore settingsStore;
static const unstoppableDomains = [
"888",
"academy",
"agency",
"altimist",
"anime",
"austin",
"bald",
"bay",
"benji",
"bet",
"binanceus",
"bitcoin",
"bitget",
"bitscrunch",
"blockchain",
"boomer",
"boston",
"ca",
"caw",
"cc",
"chat",
"chomp",
"clay",
"club",
"co",
"com",
"company",
"crypto",
"dao",
"design",
"dfz",
"digital",
"doga",
"donut",
"dream",
"email",
"emir",
"eth",
"ethermail",
"family",
"farms",
"finance",
"fun",
"fyi",
"games",
"global",
"go",
"group",
"guru",
"hi",
"hockey",
"host",
"info",
"io",
"klever",
"kresus",
"kryptic",
"lfg",
"life",
"live",
"llc",
"ltc",
"ltd",
"manga",
"me",
"media",
"metropolis",
"miami",
"miku",
"money",
"moon",
"mumu",
"net",
"network",
"news",
"nft",
"npc",
"onchain",
"online",
"org",
"podcast",
"pog",
"polygon",
"base",
"arbitrum"
"press",
"privacy",
"pro",
"propykeys",
"pudgy",
"pw",
"quantum",
"rad",
"raiin",
"retardio",
"rip",
"rocks",
"secret",
"services",
"site",
"smobler",
"social",
"solutions",
"space",
"stepn",
"store",
"studio",
"systems",
"tball",
"tea",
"team",
"tech",
"technology",
"today",
"tribe",
"u",
"ubu",
"uno",
"unstoppable",
"vip",
"wallet",
"website",
"wif",
"wifi",
"witg",
"work",
"world",
"wrkx",
"wtf",
"x",
"xmr",
"xyz",
"zil",
"zone",
"pizza"
];
static String? extractAddressByType(
{required String raw,
required CryptoCurrency type,
@@ -196,17 +56,6 @@ class AddressResolver {
});
}
static String extractUnstoppableDomain(String raw) {
// sort by length to avoid matching shorter tld instead of longer
final domains = List<String>.from(unstoppableDomains)..sort((a, b) => b.length.compareTo(a.length));
for (final tld in domains) {
final pattern = RegExp(r'([A-Za-z0-9-]+)\.' + RegExp.escape(tld), caseSensitive: false);
final match = pattern.firstMatch(raw);
if (match != null) return match.group(0)!;
}
return '';
}
bool isEmailFormat(String address) {
final RegExp emailRegex = RegExp(
r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
@@ -218,167 +67,7 @@ class AddressResolver {
Future<ParsedAddress> resolve(BuildContext context, String text, CryptoCurrency currency) async {
final ticker = currency.title;
try {
if (text.startsWith("zcash.me")) {
final parts = text.split("/");
final handle = parts.last;
if (parts.length == 2 && handle.isNotEmpty) {
final extractZcashAddress = await _fetchZcashAddress(handle);
if (extractZcashAddress != null) {
return ParsedAddress.zcashAddress(
address: extractZcashAddress,
name: handle,
);
}
}
}
// twitter handle example: @username
if (text.startsWith('@') && !text.substring(1).contains('@')) {
if (currency == CryptoCurrency.zano && settingsStore.lookupsZanoAlias) {
final formattedName = text.substring(1);
final zanoAddress = await ZanoAlias.fetchZanoAliasAddress(formattedName);
if (zanoAddress != null && zanoAddress.isNotEmpty) {
return ParsedAddress.zanoAddress(
address: zanoAddress,
name: text,
);
}
}
if (settingsStore.lookupsTwitter) {
final formattedName = text.substring(1);
final twitterUser = await TwitterApi.lookupUserByName(userName: formattedName);
final addressFromBio = extractAddressByType(
raw: twitterUser.description,
type: CryptoCurrency.fromString(ticker, walletCurrency: wallet.currency));
if (addressFromBio != null && addressFromBio.isNotEmpty) {
return ParsedAddress.fetchTwitterAddress(
address: addressFromBio,
name: text,
profileImageUrl: twitterUser.profileImageUrl,
profileName: twitterUser.name);
} else {
final domain = extractUnstoppableDomain(twitterUser.description);
if (domain.isNotEmpty) {
final parsedAddressFromDomain = await resolve(context, domain, currency);
if (parsedAddressFromDomain.addresses.isNotEmpty &&
parsedAddressFromDomain.addresses.first != domain) {
return ParsedAddress(
addresses: parsedAddressFromDomain.addresses,
name: text,
profileImageUrl: twitterUser.profileImageUrl,
profileName: twitterUser.name,
parseFrom: ParseFrom.twitter,
);
}
}
}
final addressFromLocation = extractAddressByType(
raw: twitterUser.location,
type: CryptoCurrency.fromString(ticker,
walletCurrency: wallet.currency), requireSurroundingWhitespaces: false);
if (addressFromLocation != null && addressFromLocation.isNotEmpty) {
return ParsedAddress.fetchTwitterAddress(
address: addressFromLocation,
name: text,
profileImageUrl: twitterUser.profileImageUrl,
profileName: twitterUser.name);
} else {
final domain = extractUnstoppableDomain(twitterUser.location);
if (domain.isNotEmpty) {
final parsedAddressFromDomain = await resolve(context, domain, currency);
if (parsedAddressFromDomain.addresses.isNotEmpty &&
parsedAddressFromDomain.addresses.first != domain) {
return ParsedAddress(
addresses: parsedAddressFromDomain.addresses,
name: text,
profileImageUrl: twitterUser.profileImageUrl,
profileName: twitterUser.name,
parseFrom: ParseFrom.twitter,
);
}
}
}
final pinnedTweet = twitterUser.pinnedTweet?.text;
if (pinnedTweet != null) {
final addressFromPinnedTweet = extractAddressByType(
raw: pinnedTweet,
type: CryptoCurrency.fromString(ticker, walletCurrency: wallet.currency),
requireSurroundingWhitespaces: true);
if (addressFromPinnedTweet != null) {
return ParsedAddress.fetchTwitterAddress(
address: addressFromPinnedTweet,
name: text,
profileImageUrl: twitterUser.profileImageUrl,
profileName: twitterUser.name);
} else {
final domain = extractUnstoppableDomain(pinnedTweet);
if (domain.isNotEmpty) {
final parsedAddressFromDomain = await resolve(context, domain, currency);
if (parsedAddressFromDomain.addresses.isNotEmpty &&
parsedAddressFromDomain.addresses.first != domain) {
return ParsedAddress(
addresses: parsedAddressFromDomain.addresses,
name: text,
profileImageUrl: twitterUser.profileImageUrl,
profileName: twitterUser.name,
parseFrom: ParseFrom.twitter,
);
}
}
}
}
}
}
// Mastodon example: @username@hostname.xxx
if (text.startsWith('@') && text.contains('@', 1) && text.contains('.', 1)) {
if (settingsStore.lookupsMastodon) {
final subText = text.substring(1);
final hostNameIndex = subText.indexOf('@');
final hostName = subText.substring(hostNameIndex + 1);
final userName = subText.substring(0, hostNameIndex);
final mastodonUser =
await MastodonAPI.lookupUserByUserName(userName: userName, apiHost: hostName);
if (mastodonUser != null) {
String? addressFromBio = extractAddressByType(raw: mastodonUser.note, type: currency, requireSurroundingWhitespaces: true);
if (addressFromBio != null && addressFromBio.isNotEmpty) {
return ParsedAddress.fetchMastodonAddress(
address: addressFromBio,
name: text,
profileImageUrl: mastodonUser.profileImageUrl,
profileName: mastodonUser.username);
} else {
final pinnedPosts =
await MastodonAPI.getPinnedPosts(userId: mastodonUser.id, apiHost: hostName);
if (pinnedPosts.isNotEmpty) {
final userPinnedPostsText = pinnedPosts.map((item) => item.content).join('\n');
String? addressFromPinnedPost =
extractAddressByType(raw: userPinnedPostsText, type: currency,
requireSurroundingWhitespaces: true);
if (addressFromPinnedPost != null && addressFromPinnedPost.isNotEmpty) {
return ParsedAddress.fetchMastodonAddress(
address: addressFromPinnedPost,
name: text,
profileImageUrl: mastodonUser.profileImageUrl,
profileName: mastodonUser.username);
}
}
}
}
}
}
// .well-known scheme:
// user@domain identifiers: resolved against the domain the user typed.
if (text.contains('.') && text.contains('@')) {
if (settingsStore.lookupsWellKnown) {
final record =
@@ -397,33 +86,6 @@ class AddressResolver {
}
}
if (!text.startsWith('@') && text.contains('@') && !text.contains('.')) {
final bool isFioRegistered = await FioAddressProvider.checkAvail(text);
if (isFioRegistered) {
final address = await FioAddressProvider.getPubAddress(text, ticker);
return ParsedAddress.fetchFioAddress(address: address, name: text);
}
}
if (text.hasOnlyEmojis) {
if (settingsStore.lookupsYatService) {
final addresses = await yatService.fetchYatAddress(text, ticker);
return ParsedAddress.fetchEmojiAddress(addresses: addresses, name: text);
}
}
final isNormalAddress = extractAddressByType(raw: text, type: currency)?.isNotEmpty ?? false;
if (text.length <= 30 && !isNormalAddress) {
final thorChainAddress = await ThorChainExchangeProvider.lookupAddressByName(text);
if (thorChainAddress != null && thorChainAddress.isNotEmpty) {
String? address =
thorChainAddress[ticker] ?? (ticker == 'RUNE' ? thorChainAddress['THOR'] : null);
if (address != null) {
return ParsedAddress.thorChainAddress(address: address, name: text);
}
}
}
final formattedName = OpenaliasRecord.formatDomainName(text);
final domainParts = formattedName.split('.');
final name = domainParts.last;
@@ -432,15 +94,6 @@ class AddressResolver {
return ParsedAddress(addresses: [text]);
}
if (unstoppableDomains.any((domain) => name.trim() == domain)) {
if (settingsStore.lookupsUnstoppableDomains) {
final address = await fetchUnstoppableDomainAddress(text, ticker);
if (address.isNotEmpty) {
return ParsedAddress.fetchUnstoppableDomainAddress(address: address, name: text);
}
}
}
final bip353AddressMap = await Bip353Record.fetchUriByCryptoCurrency(text, ticker);
if (bip353AddressMap != null && bip353AddressMap.isNotEmpty) {
@@ -476,52 +129,10 @@ class AddressResolver {
}
}
}
if (isEmailFormat(text)) {
final nostrProfile = await NostrProfileHandler.queryProfile(context, text);
if (nostrProfile?.relays != null) {
final nostrUserData =
await NostrProfileHandler.processRelays(context, nostrProfile!, text);
if (nostrUserData != null) {
String? addressFromBio = extractAddressByType(raw: nostrUserData.about, type: currency);
if (addressFromBio != null && addressFromBio.isNotEmpty) {
return ParsedAddress.nostrAddress(
address: addressFromBio,
name: text,
profileImageUrl: nostrUserData.picture,
profileName: nostrUserData.name);
}
}
}
}
} catch (e) {
printV(e.toString());
}
return ParsedAddress(addresses: [text]);
}
Future<String?> _fetchZcashAddress(String handle) async {
final url = Uri.parse('https://zcash.me/$handle');
try {
final response = await ProxyWrapper().get(clearnetUri: url);
if (response.statusCode == 200) {
final addressRegex = RegExp(
r'(t1[0-9A-Za-z]{33}|t3[0-9A-Za-z]{33}|zs[a-z0-9]{76}|u1[a-z0-9]{1,300})',
caseSensitive: true,
);
final match = addressRegex.firstMatch(response.body);
if (match != null) {
return match.group(0);
}
}
} catch (e) {
printV('Error fetching zcash.me profile: $e');
}
return null;
}
}
@@ -1,30 +0,0 @@
import 'dart:convert';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/utils/proxy_wrapper.dart';
Future<String> fetchUnstoppableDomainAddress(String domain, String ticker) async {
var address = '';
try {
final uri = Uri.parse("https://api.unstoppabledomains.com/profile/public/${Uri.encodeQueryComponent(domain)}?fields=records");
final response = await ProxyWrapper().get(clearnetUri: uri);
final jsonParsed = json.decode(response.body) as Map<String, dynamic>;
if (jsonParsed["records"] == null) {
throw Exception(".records response from $uri is empty");
};
final records = jsonParsed["records"] as Map<String, dynamic>;
final key = "crypto.${ticker.toUpperCase()}.address";
if (records[key] == null) {
throw Exception(".records.${key} response from $uri is empty");
}
return records[key] as String? ?? '';
} catch (e) {
printV('Unstoppable domain error: ${e.toString()}');
address = '';
}
return address;
}
-29
View File
@@ -1,29 +0,0 @@
import 'dart:convert';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/utils/proxy_wrapper.dart';
class ZanoAlias {
static Future<String?> fetchZanoAliasAddress(String alias) async {
try {
final uri = Uri.parse("http://37.27.100.59:10500/json_rpc");
final response = await ProxyWrapper().post(
clearnetUri: uri,
body: json.encode({
"id": 0,
"jsonrpc": "2.0",
"method": "get_alias_details",
"params": {"alias": alias}
}),
);
final jsonParsed = json.decode(response.body) as Map<String, dynamic>;
return jsonParsed['result']['alias_details']['address'] as String?;
} catch (e) {
printV('Zano Alias error: ${e.toString()}');
}
return null;
}
}
-66
View File
@@ -1,66 +0,0 @@
import 'dart:convert';
import 'package:cw_core/utils/proxy_wrapper.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:hash_wallet/mastodon/mastodon_user.dart';
class MastodonAPI {
static const httpsScheme = 'https';
static const userPath = '/api/v1/accounts/lookup';
static const statusesPath = '/api/v1/accounts/:id/statuses';
static Future<MastodonUser?> lookupUserByUserName(
{required String userName, required String apiHost}) async {
try {
final queryParams = {'acct': userName};
final uri = Uri(
scheme: httpsScheme,
host: apiHost,
path: userPath,
queryParameters: queryParams,
);
final response = await ProxyWrapper().get(clearnetUri: uri);
if (response.statusCode != 200) return null;
final Map<String, dynamic> responseJSON = json.decode(response.body) as Map<String, dynamic>;
return MastodonUser.fromJson(responseJSON);
} catch (e) {
printV('Error in lookupUserByUserName: $e');
return null;
}
}
static Future<List<PinnedPost>> getPinnedPosts({
required String userId,
required String apiHost,
}) async {
try {
final queryParams = {'pinned': 'true'};
final uri = Uri(
scheme: httpsScheme,
host: apiHost,
path: statusesPath.replaceAll(':id', userId),
queryParameters: queryParams,
);
final response = await ProxyWrapper().get(clearnetUri: uri);
if (response.statusCode != 200) {
throw Exception('Unexpected HTTP status: ${response.statusCode}');
}
final List<dynamic> responseJSON = json.decode(response.body) as List<dynamic>;
return responseJSON.map((json) => PinnedPost.fromJson(json as Map<String, dynamic>)).toList();
} catch (e) {
printV('Error in getPinnedPosts: $e');
throw e;
}
}
}
-39
View File
@@ -1,39 +0,0 @@
class MastodonUser {
String id;
String username;
String profileImageUrl;
String acct;
String note;
MastodonUser({
required this.id,
required this.username,
required this.profileImageUrl,
required this.acct,
required this.note,
});
factory MastodonUser.fromJson(Map<String, dynamic> json) {
return MastodonUser(
id: json['id'] as String,
username: json['username'] as String? ?? '',
acct: json['acct'] as String,
note: json['note'] as String,
profileImageUrl: json['avatar'] as String? ?? ''
);
}
}
class PinnedPost {
final String id;
final String content;
PinnedPost({required this.id, required this.content});
factory PinnedPost.fromJson(Map<String, dynamic> json) {
return PinnedPost(
id: json['id'] as String,
content: json['content'] as String,
);
}
}
-150
View File
@@ -1,150 +0,0 @@
import 'dart:convert';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/nostr/nostr_user.dart';
import 'package:hash_wallet/src/widgets/alert_with_one_action.dart';
import 'package:hash_wallet/src/widgets/picker.dart';
import 'package:hash_wallet/utils/show_pop_up.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:flutter/material.dart';
import 'package:nostr_tools/nostr_tools.dart';
class NostrProfileHandler {
static final relayToDomainMap = {
'relay.snort.social': 'snort.social',
};
static Nip05 _nip05 = Nip05();
static Future<ProfilePointer?> queryProfile(BuildContext context, String nip05Address) async {
var profile = await _nip05.queryProfile(nip05Address);
if (profile?.pubkey != null) {
if (profile?.relays?.isNotEmpty == true) {
return profile;
} else {
await _showErrorDialog(context, S.of(context).no_relays, S.of(context).no_relays_message);
}
}
return null;
}
static Future<UserMetadata?> processRelays(
BuildContext context,
ProfilePointer profile,
String nip05Address,
) async {
String userDomain = _extractDomain(nip05Address);
const int metaData = 0;
for (String relayUrl in profile.relays ?? []) {
final relayDomain = _getDomainFromRelayUrl(relayUrl);
final formattedRelayDomain = relayToDomainMap[relayDomain] ?? relayDomain;
if (formattedRelayDomain == userDomain) {
final userDomainData = await _fetchInfoFromRelay(relayUrl, profile.pubkey, [metaData]);
if (userDomainData != null) {
return userDomainData;
}
}
}
await _showErrorDialog(context, S.of(context).no_relays, S.of(context).no_relay_on_domain);
String? chosenRelayUrl =
await _showRelayChoiceDialog(context, profile.relays ?? []);
if (chosenRelayUrl != null) {
final userData = await _fetchInfoFromRelay(chosenRelayUrl, profile.pubkey, [metaData]);
if (userData != null) {
return userData;
}
}
return null;
}
static Future<UserMetadata?> _fetchInfoFromRelay(
String relayUrl, String userPubKey, List<int> kinds) async {
try {
final relay = RelayApi(relayUrl: relayUrl);
final stream = await relay.connect();
relay.sub([
Filter(
kinds: kinds,
authors: [userPubKey],
)
]);
await for (var message in stream) {
if (message.type == 'EVENT') {
final event = message.message as Event;
final eventContent = json.decode(event.content) as Map<String, dynamic>;
final userMetadata = UserMetadata.fromJson(eventContent);
relay.close();
return userMetadata;
}
}
relay.close();
return null;
} catch (e) {
printV('[!] Error with relay $relayUrl: $e');
return null;
}
}
static Future<void> _showErrorDialog(
BuildContext context, String title, String errorMessage) async {
if (context.mounted) {
await showPopUp<void>(
context: context,
builder: (BuildContext dialogContext) {
return AlertWithOneAction(
alertTitle: title,
alertContent: errorMessage,
buttonText: S.of(dialogContext).ok,
buttonAction: () => Navigator.of(dialogContext).pop(),
);
},
);
}
}
static String _extractDomain(String nip05Address) {
var parts = nip05Address.split('@');
return parts.length == 2 ? parts[1] : '';
}
static String _getDomainFromRelayUrl(String relayUrl) {
try {
var uri = Uri.parse(relayUrl);
return uri.host;
} catch (e) {
printV('Error parsing URL: $e');
return '';
}
}
static Future<String?> _showRelayChoiceDialog(
BuildContext context,
List<String> relays,
) async {
String? selectedRelay;
if (context.mounted) {
await showPopUp<void>(
context: context,
builder: (BuildContext dialogContext) {
return Picker<String>(
selectedAtIndex: 0,
title: S.of(dialogContext).choose_relay,
items: relays,
onItemSelected: (String relay) => selectedRelay = relay,
);
},
);
}
return selectedRelay;
}
}
-35
View File
@@ -1,35 +0,0 @@
class UserMetadata {
final String name;
final String lnurl;
final String email;
final String picture;
final String about;
final String nip05;
final String banner;
final String website;
UserMetadata({
required this.name,
required this.lnurl,
required this.email,
required this.picture,
required this.about,
required this.nip05,
required this.banner,
required this.website,
});
factory UserMetadata.fromJson(Map<String, dynamic> json) {
return UserMetadata(
name: json['name'] as String? ?? '',
lnurl: json['lud06'] as String? ?? '',
email: json['lud16'] as String? ?? '',
picture: json['picture'] as String? ?? '',
about: json['about'] as String? ?? '',
nip05: json['nip05'] as String? ?? '',
banner: json['banner'] as String? ?? '',
website: json['website'] as String? ?? '',
);
}
}
@@ -21,22 +21,6 @@ class DomainLookupsPage extends BasePage {
padding: EdgeInsets.only(top: 10),
child: Column(
children: [
SettingsSwitcherCell(
title: 'Twitter',
value: _connectionsSyncViewModel.lookupTwitter,
onValueChange: (_, bool value) => _connectionsSyncViewModel.setLookupsTwitter(value)),
SettingsSwitcherCell(
title: 'Mastodon',
value: _connectionsSyncViewModel.looksUpMastodon,
onValueChange: (_, bool value) => _connectionsSyncViewModel.setLookupsMastodon(value)),
SettingsSwitcherCell(
title: 'Yat service',
value: _connectionsSyncViewModel.looksUpYatService,
onValueChange: (_, bool value) => _connectionsSyncViewModel.setLookupsYatService(value)),
SettingsSwitcherCell(
title: 'Unstoppable Domains',
value: _connectionsSyncViewModel.looksUpUnstoppableDomains,
onValueChange: (_, bool value) => _connectionsSyncViewModel.setLookupsUnstoppableDomains(value)),
SettingsSwitcherCell(
title: 'OpenAlias',
value: _connectionsSyncViewModel.looksUpOpenAlias,
@@ -49,12 +33,6 @@ class DomainLookupsPage extends BasePage {
title: '.well-known',
value: _connectionsSyncViewModel.looksUpWellKnown,
onValueChange: (_, bool value) => _connectionsSyncViewModel.setLookupsWellKnown(value)),
SettingsSwitcherCell(
title: 'Zano Aliases',
value: _connectionsSyncViewModel.lookupsZanoAlias,
onValueChange: (_, bool value) => _connectionsSyncViewModel.setLookupsZanoAlias(value)),
//if (!isHaven) it does not work correctly
],
),
);
-67
View File
@@ -1,67 +0,0 @@
import 'dart:convert';
import 'package:hash_wallet/.secrets.g.dart' as secrets;
import 'package:hash_wallet/twitter/twitter_user.dart';
import 'package:cw_core/utils/proxy_wrapper.dart';
class TwitterApi {
static const twitterBearerToken = secrets.twitterBearerToken;
static const httpsScheme = 'https';
static const apiHost = 'api.twitter.com';
static const userPath = '/2/users/by/username/';
static Future<TwitterUser> lookupUserByName({required String userName}) async {
final queryParams = {
'user.fields': 'description,profile_image_url,location',
'expansions': 'pinned_tweet_id',
'tweet.fields': 'note_tweet'
};
final headers = {'authorization': 'Bearer $twitterBearerToken'};
final uri = Uri(
scheme: httpsScheme,
host: apiHost,
path: userPath + userName,
queryParameters: queryParams);
final response = await ProxyWrapper().get(clearnetUri: uri, headers: headers).catchError((error) {
throw Exception('HTTP request failed: $error');
});
if (response.statusCode != 200) {
throw Exception('Unexpected http status: ${response.statusCode}');
}
final Map<String, dynamic> responseJSON = jsonDecode(response.body) as Map<String, dynamic>;
if (responseJSON['errors'] != null &&
!responseJSON['errors'][0]['detail']
.toString()
.contains("Could not find tweet with pinned_tweet_id")) {
throw Exception(responseJSON['errors'][0]['detail']);
}
return TwitterUser.fromJson(responseJSON, _getPinnedTweet(responseJSON));
}
static Tweet? _getPinnedTweet(Map<String, dynamic> responseJSON) {
try {
final tweetId = responseJSON['data']['pinned_tweet_id'] as String?;
if (tweetId == null || responseJSON['includes'] == null) return null;
final tweetIncludes = List.from(responseJSON['includes']['tweets'] as List);
final pinnedTweetData = tweetIncludes.firstWhere(
(tweet) => tweet['id'] == tweetId,
orElse: () => null,
) as Map<String, dynamic>?;
if (pinnedTweetData == null) return null;
final pinnedTweetText =
(pinnedTweetData['note_tweet']?['text'] ?? pinnedTweetData['text']) as String;
return Tweet(id: tweetId, text: pinnedTweetText);
} catch (e) {
return null;
}
}
}
-49
View File
@@ -1,49 +0,0 @@
class TwitterUser {
TwitterUser(
{required this.id,
required this.username,
required this.name,
required this.description,
required this.location,
required this.profileImageUrl,
this.pinnedTweet});
final String id;
final String username;
final String name;
final String description;
final String location;
final String profileImageUrl;
final Tweet? pinnedTweet;
factory TwitterUser.fromJson(Map<String, dynamic> json, [Tweet? pinnedTweet]) {
final profileImageUrl = json['data']['profile_image_url'] as String? ?? '';
final scaledProfileImageUrl = profileImageUrl.replaceFirst('normal', '200x200');
return TwitterUser(
id: json['data']['id'] as String,
username: json['data']['username'] as String? ?? '',
name: json['data']['name'] as String,
description: json['data']['description'] as String? ?? '',
location: json['data']['location'] as String? ?? '',
profileImageUrl: scaledProfileImageUrl,
pinnedTweet: pinnedTweet,
);
}
}
class Tweet {
Tweet({
required this.id,
required this.text,
});
final String id;
final String text;
factory Tweet.fromJson(Map<String, dynamic> json) {
return Tweet(
id: json['id'] as String,
text: json['text'] as String,
);
}
}
+3 -3
View File
@@ -121,13 +121,13 @@ class TranslationChecker {
required String destinationFile,
}) {
return '''
You are a professional translator checking the accuracy of a translation for a cryptocurrency wallet called Cake Wallet.
You are a professional translator checking the accuracy of a translation for a cryptocurrency wallet called Hash Bags.
source file is: ${sourceFile}, destination file is: ${destinationFile}.
Rules you must obey:
- respect company branding and style (Cake Wallet, Cake Pay, Bird Pay and other brandings stay the same no matter the language)
- be accurate and remember that Cake Wallet lets you hold cryptocurrency, so use words that are associated with cryptocurrency and wallets more preferably than words that are associated with banks.
- respect company branding and style (the product name "Hash Bags" stays the same no matter the language; never introduce "Cake Wallet" or other third-party brand names)
- be accurate and remember that Hash Bags lets you hold cryptocurrency, so use words that are associated with cryptocurrency and wallets more preferably than words that are associated with banks.
- elements with \${value} should remain non-translated as they are used by codegen to dynamically insert the value.
- make sure that the translation that you output improves the original translation, and doesn't change the meaning of it.
- if original translation contains abbreviations, make sure to translate them as well, and do not expand them.