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:** **Platform:**
- OS: [e.g. iOS 15.1, Android 14] - OS: [e.g. iOS 15.1, Android 14]
- Device: [e.g. iPhone 14, Galaxy S21] - 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** **Additional context**
+4 -7
View File
@@ -1,11 +1,8 @@
blank_issues_enabled: false blank_issues_enabled: false
contact_links: contact_links:
- name: Feature or Enhancement Request ✨ - name: Feature or Enhancement Request ✨
url: https://github.com/cake-tech/cake_wallet/discussions/new?category=feature-requests url: https://github.com/Such-Software/hash-wallet/discussions
about: Suggest an idea for Cake Wallet about: Suggest an idea for Hash Bags
- name: Not sure where to start?
url: https://docs.cakewallet.com
about: Start by reading checking out the guides!
- name: Need help? - name: Need help?
url: https://cakewallet.com/#contact url: https://hash.boats
about: Use our live chat or send a support email! about: Guides, support, and contact info
+22 -1
View File
@@ -1,6 +1,6 @@
# Privacy Policy # Privacy Policy
**Effective: July 2, 2026** **Effective: August 15, 2026**
The canonical, always-current version of this policy is published at The canonical, always-current version of this policy is published at
**https://hash.boats/privacy**. This file mirrors it for the source repository; **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 - **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 service we operate that aggregates public sources. It sees your IP and the
currencies you display — not your addresses or balances. 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, - **Trocador (swaps).** When you start a swap, order details (input/output coin,
amount, receiving address) are sent to Trocador's API. Trocador is an amount, receiving address) are sent to Trocador's API. Trocador is an
independent controller with its own policy at independent controller with its own policy at
+6 -6
View File
@@ -748,7 +748,7 @@ abstract class ElectrumWalletBase
if (await checkIfMempoolAPIIsEnabled() && type == WalletType.bitcoin) { if (await checkIfMempoolAPIIsEnabled() && type == WalletType.bitcoin) {
try { try {
final response = await ProxyWrapper() 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)); .timeout(Duration(seconds: 15));
final result = json.decode(response.body) as Map<String, dynamic>; final result = json.decode(response.body) as Map<String, dynamic>;
@@ -2292,7 +2292,7 @@ abstract class ElectrumWalletBase
final blockHash = await ProxyWrapper() final blockHash = await ProxyWrapper()
.get( .get(
clearnetUri: Uri.parse( clearnetUri: Uri.parse(
"https://mempool.cakewallet.com/api/v1/block-height/$height", "https://mempool.space/api/block-height/$height",
), ),
) )
.timeout(Duration(seconds: 15)); .timeout(Duration(seconds: 15));
@@ -2303,7 +2303,7 @@ abstract class ElectrumWalletBase
final blockResponse = await ProxyWrapper() final blockResponse = await ProxyWrapper()
.get( .get(
clearnetUri: Uri.parse( clearnetUri: Uri.parse(
"https://mempool.cakewallet.com/api/v1/block/${blockHash.body}", "https://mempool.space/api/block/${blockHash.body}",
), ),
) )
.timeout(Duration(seconds: 15)); .timeout(Duration(seconds: 15));
@@ -3137,7 +3137,7 @@ abstract class ElectrumWalletBase
final blockHashResp = await ProxyWrapper() final blockHashResp = await ProxyWrapper()
.get( .get(
clearnetUri: Uri.parse( clearnetUri: Uri.parse(
'https://mempool.cakewallet.com/api/v1/block-height/$h', 'https://mempool.space/api/block-height/$h',
), ),
) )
.timeout(const Duration(seconds: 15)); .timeout(const Duration(seconds: 15));
@@ -3150,7 +3150,7 @@ abstract class ElectrumWalletBase
final blockResp = await ProxyWrapper() final blockResp = await ProxyWrapper()
.get( .get(
clearnetUri: Uri.parse( clearnetUri: Uri.parse(
'https://mempool.cakewallet.com/api/v1/block/$blockHash', 'https://mempool.space/api/block/$blockHash',
), ),
) )
.timeout(const Duration(seconds: 15)); .timeout(const Duration(seconds: 15));
@@ -4086,7 +4086,7 @@ Future<void> _handleScanSilentPayments(ScanData scanData) async {
// So, if blockDate exists, reuse // So, if blockDate exists, reuse
if (isDateNow) { if (isDateNow) {
try { try {
final rootURL = "https://cake.mempool.space"; final rootURL = "https://mempool.space";
final tweakBlockHash = await ProxyWrapper() final tweakBlockHash = await ProxyWrapper()
.get(clearnetUri: Uri.parse("$rootURL/api/block-height/$tweakHeight")) .get(clearnetUri: Uri.parse("$rootURL/api/block-height/$tweakHeight"))
.timeout(Duration(seconds: 15)); .timeout(Duration(seconds: 15));
+1 -1
View File
@@ -127,7 +127,7 @@ Future<int> getBitcoinHeightByDateAPI({required DateTime date}) async {
final req = await ProxyWrapper() final req = await ProxyWrapper()
.getHttpClient() .getHttpClient()
.getUrl(Uri.parse( .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)); .timeout(Duration(seconds: 15));
final response = await req.close(); final response = await req.close();
final stringResponse = await response.transform(utf8.decoder).join(); 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/trade_monitor.dart';
import 'package:hash_wallet/core/wallet_creation_service.dart'; import 'package:hash_wallet/core/wallet_creation_service.dart';
import 'package:hash_wallet/core/wallet_loading_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/biometric_auth.dart';
import 'package:hash_wallet/entities/bridge_transfer.dart'; import 'package:hash_wallet/entities/bridge_transfer.dart';
import 'package:hash_wallet/entities/contact.dart'; import 'package:hash_wallet/entities/contact.dart';
@@ -1580,10 +1579,7 @@ Future<void> setup({
param1: item, param2: unspentCoinsListViewModel)); param1: item, param2: unspentCoinsListViewModel));
}); });
getIt.registerFactory(() => YatService());
getIt.registerFactory(() => AddressResolver( getIt.registerFactory(() => AddressResolver(
yatService: getIt.get<YatService>(),
wallet: getIt.get<AppStore>().wallet!, wallet: getIt.get<AppStore>().wallet!,
settingsStore: getIt.get<SettingsStore>())); 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/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/ens_record.dart';
import 'package:hash_wallet/entities/lnurlpay_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/openalias_record.dart';
import 'package:hash_wallet/entities/parsed_address.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/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/store/settings_store.dart';
import 'package:hash_wallet/twitter/twitter_api.dart';
import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/utils/print_verbose.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_base.dart';
import 'package:cw_core/wallet_type.dart'; import 'package:cw_core/wallet_type.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'bip_353_record.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 { class AddressResolver {
AddressResolver({required this.yatService, required this.wallet, required this.settingsStore}) AddressResolver({required this.wallet, required this.settingsStore})
: walletType = wallet.type; : walletType = wallet.type;
final YatService yatService;
final WalletType walletType; final WalletType walletType;
final WalletBase wallet; final WalletBase wallet;
final SettingsStore settingsStore; 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( static String? extractAddressByType(
{required String raw, {required String raw,
required CryptoCurrency type, 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) { bool isEmailFormat(String address) {
final RegExp emailRegex = RegExp( final RegExp emailRegex = RegExp(
r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', 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 { Future<ParsedAddress> resolve(BuildContext context, String text, CryptoCurrency currency) async {
final ticker = currency.title; final ticker = currency.title;
try { try {
if (text.startsWith("zcash.me")) { // user@domain identifiers: resolved against the domain the user typed.
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:
if (text.contains('.') && text.contains('@')) { if (text.contains('.') && text.contains('@')) {
if (settingsStore.lookupsWellKnown) { if (settingsStore.lookupsWellKnown) {
final record = 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 formattedName = OpenaliasRecord.formatDomainName(text);
final domainParts = formattedName.split('.'); final domainParts = formattedName.split('.');
final name = domainParts.last; final name = domainParts.last;
@@ -432,15 +94,6 @@ class AddressResolver {
return ParsedAddress(addresses: [text]); 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); final bip353AddressMap = await Bip353Record.fetchUriByCryptoCurrency(text, ticker);
if (bip353AddressMap != null && bip353AddressMap.isNotEmpty) { 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) { } catch (e) {
printV(e.toString()); printV(e.toString());
} }
return ParsedAddress(addresses: [text]); 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), padding: EdgeInsets.only(top: 10),
child: Column( child: Column(
children: [ 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( SettingsSwitcherCell(
title: 'OpenAlias', title: 'OpenAlias',
value: _connectionsSyncViewModel.looksUpOpenAlias, value: _connectionsSyncViewModel.looksUpOpenAlias,
@@ -49,12 +33,6 @@ class DomainLookupsPage extends BasePage {
title: '.well-known', title: '.well-known',
value: _connectionsSyncViewModel.looksUpWellKnown, value: _connectionsSyncViewModel.looksUpWellKnown,
onValueChange: (_, bool value) => _connectionsSyncViewModel.setLookupsWellKnown(value)), 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, required String destinationFile,
}) { }) {
return ''' 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}. source file is: ${sourceFile}, destination file is: ${destinationFile}.
Rules you must obey: 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) - 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 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. - 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. - 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. - 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. - if original translation contains abbreviations, make sure to translate them as well, and do not expand them.