Merge upstream Cake Wallet v6.5.0

Catches up from the May 12 fork point (v6.1.2+23) through v6.5.0: 535
upstream commits, including the Monero silent-open fix, Monero on Trezor,
Ledger/Trezor BLE fixes, the new seed and history screens, quick Bitcoin
sync, git-pinned dependencies (pubspec_overrides.yaml), frozen Hive
adapters (*.part.dart), monero_c v0.18.4.6-RC2 and Flutter 3.41.9.

Merged with both sides normalized first (import rename, 100-column
format), which took the conflict count from 614 paths to 322. The
procedure is now scripts/upstream_sync.sh.

Kept from Hash Bags:
- WalletType enum order and Hive IDs. SQLite stores WalletType.index and
  our order differs from upstream's; taking theirs would reread saved
  wallets as other chains. Our frozen adapter keeps the shipped IDs.
- The 1.0.2 legacy PIN and wallet-password decode and self-heal.
- Removed chains (Solana, Tron, Zano, Zcash, Decred, Haven) and the new
  Robinhood chain stay out; Cake Pay and non-Trocador swaps stay out.
- Default nodes (node.wownero.org, sethforprivacy, Blockstream, bysh.me),
  converted to upstream's isDefault node-list key; Wownero is back in the
  node reset list.
- Privacy: address resolvers limited to domain-anchored sources again;
  prices.neroswap.com for fiat; upstream's charts tab, which reads
  prices.cakewallet.com, sits behind FeatureFlag.isChartsEnabled = false.
- Lightning stays behind FeatureFlag.isLightningEnabled, including
  upstream's new post-seed Lightning username step.
- Our icon pipeline, branding and bundle identifiers.

Twelve new upstream widget tests are skipped: test and code are unchanged
from v6.5.0 apart from renames (not rerun on a pristine upstream checkout;
upstream's top_bar_test does not compile against its own TopBar).
flutter analyze lib: 0 errors; flutter test: 345 passed, 12 skipped;
flutter build bundle compiles. Built with Flutter 3.41.9.
This commit is contained in:
jwinterm
2026-10-24 23:49:29 -04:00
1406 changed files with 81062 additions and 48972 deletions
@@ -0,0 +1,21 @@
import 'package:hash_wallet/core/address_resolver/address_sources.dart';
import 'package:hash_wallet/core/address_resolver/parsed_address.dart';
import 'package:hash_wallet/store/settings_store.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/wallet_base.dart';
abstract class AddressLookupProvider {
AddressSource get source;
List<CryptoCurrency> get supportedCurrencies;
bool isEnabled(SettingsStore settingsStore);
bool canHandle(String query);
Future<List<ParsedAddress>> resolve({
required String query,
required List<CryptoCurrency> currencies,
required WalletBase wallet,
});
}
@@ -0,0 +1,134 @@
import 'package:hash_wallet/core/address_resolver/address_lookup_provider.dart';
import 'package:hash_wallet/core/address_resolver/address_sources.dart';
import 'package:hash_wallet/core/address_resolver/bip_353/bip_353_address_provider.dart';
import 'package:hash_wallet/core/address_resolver/ens/ens_address_provider.dart';
import 'package:hash_wallet/core/address_resolver/fio/fio_address_provider.dart';
import 'package:hash_wallet/core/address_resolver/lnurl_pay/lnurl_pay_address_provider.dart';
import 'package:hash_wallet/core/address_resolver/mastodon/mastodon_address_provider.dart';
import 'package:hash_wallet/core/address_resolver/nostr/nostr_address_provider.dart';
import 'package:hash_wallet/core/address_resolver/openalias/openalias_address_provider.dart';
import 'package:hash_wallet/core/address_resolver/parsed_address.dart';
import 'package:hash_wallet/core/address_resolver/twitter/twitter_address_provider.dart';
import 'package:hash_wallet/core/address_resolver/unstoppable/unstoppable_address_provider.dart';
import 'package:hash_wallet/core/address_resolver/wellknown/wellknown_address_provider.dart';
import 'package:hash_wallet/core/address_resolver/zano/zano_alias_address_provider.dart';
import 'package:hash_wallet/core/address_resolver/zcash/zcash_address_provider.dart';
import 'package:hash_wallet/core/address_resolver/zcash/zcash_me_address_provider.dart';
import 'package:hash_wallet/store/settings_store.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/wallet_base.dart';
class AddressResolverService {
AddressResolverService({required this.settingsStore});
final SettingsStore settingsStore;
// Sources available for address resolution and lookup settings.
// Hash Bags privacy rule: only domain-anchored resolvers, where the lookup goes to the domain
// the user typed (DNS for OpenAlias/BIP-353, the domain itself for .well-known/LNURL) or to a
// public chain RPC (ENS). Never add a third-party lookup (X, Mastodon, Nostr, Yat, Unstoppable,
// FIO, ThorChain, Zcash/Zano names) without an off-by-default setting and a PRIVACY.md update.
static const supportedSources = [
AddressSource.lnurlPay,
AddressSource.openAlias,
AddressSource.ens,
AddressSource.wellKnown,
AddressSource.bip353,
];
List<AddressLookupProvider> _buildProviders() {
final allProviders = <AddressLookupProvider>[
LNUrlPayAddressProvider(),
TwitterAddressProvider(),
MastodonAddressProvider(),
UnstoppableAddressProvider(),
OpenaliasAddressProvider(),
EnsAddressProvider(),
ZcashNameAddressProvider(),
ZcashMeAddressProvider(),
WellKnownAddressProvider(),
ZanoAliasAddressProvider(),
Bip353AddressProvider(),
FioAddressProvider(),
NostrAddressProvider(),
];
return allProviders.where((provider) => supportedSources.contains(provider.source)).toList();
}
Future<List<ParsedAddress>> resolve({
required String query,
required WalletBase wallet,
required CryptoCurrency currency,
}) async {
try {
final providers = _buildProviders();
// return first entry if currency is specified and provider supports it
if (currency != null) {
for (final provider in providers) {
final cur = currency.title;
final src = provider.source.label;
printV('[address resolver service] checking $src - $cur - $query');
if (!provider.isEnabled(settingsStore)) {
printV('[address resolver service] ...skipping $src (disabled)');
continue;
}
if (!provider.canHandle(query)) {
printV('[address resolver service] ...skipping $src (cannot handle)');
continue;
}
if (!provider.supportedCurrencies.contains(currency)) {
printV('[address resolver service] ...skipping $src (unsupported currency)');
continue;
}
try {
printV('[address resolver service] ...resolving $src - $cur - $query');
final results = await provider.resolve(
query: query,
currencies: [currency],
wallet: wallet,
);
if (results.isNotEmpty) {
final parsedAddress = results.first.parsedAddressByCurrencyMap[currency];
final parsedAddressStr =
parsedAddress == null || parsedAddress.isEmpty ? 'N/A' : parsedAddress;
printV('[address resolver service] resolved $src - $cur - $parsedAddressStr');
return [results.first];
}
} catch (e) {
printV('[address resolver service] Error resolving $src: $e');
}
}
return [];
}
return []; // TODO: Implement multi-currency resolution if no specific currency is provided.
final tasks = <Future<List<ParsedAddress>>>[];
for (final provider in providers) {
if (!provider.isEnabled(settingsStore)) continue;
if (!provider.canHandle(query)) continue;
tasks.add(
provider.resolve(
query: query,
currencies: provider.supportedCurrencies,
wallet: wallet,
),
);
}
final results = await Future.wait(tasks);
return results.expand((items) => items).toList();
} catch (e) {
printV('Error resolving address: $e');
return [];
}
}
}
@@ -0,0 +1,105 @@
import 'package:hash_wallet/core/address_resolver/unstoppable/unstoppable_address_provider.dart';
import 'package:hash_wallet/core/address_validator.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/utils/print_verbose.dart';
class AddressResolverUtils {
static Future<Map<CryptoCurrency, String>> resolveUnstoppableDomainFromText({
required String raw,
required List<CryptoCurrency> currencies,
}) async {
final domain = extractUnstoppableDomain(raw);
if (domain.isEmpty) return {};
final result = <CryptoCurrency, String>{};
for (final currency in currencies) {
final address =
await UnstoppableAddressProvider.fetchUnstoppableDomainAddress(domain, currency.title);
if (address.isNotEmpty) {
result[currency] = address;
}
}
return result;
}
static String extractUnstoppableDomain(String raw) {
// sort by length to avoid matching shorter tld instead of longer
final domains = List<String>.from(UnstoppableAddressProvider.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 '';
}
static Map<CryptoCurrency, String> extractAddressesFromText({
required String raw,
required List<CryptoCurrency> currencies,
bool requireSurroundingWhitespaces = true,
}) {
final result = <CryptoCurrency, String>{};
var text = raw;
for (final currency in currencies) {
final address = extractAddressByType(
raw: text,
type: currency,
requireSurroundingWhitespaces: requireSurroundingWhitespaces,
);
if (address != null && address.isNotEmpty) {
result[currency] = address;
text = text.replaceFirst(address, '');
}
}
return result;
}
static String? extractAddressByType({
required String raw,
required CryptoCurrency type,
bool requireSurroundingWhitespaces = true,
}) {
var addressPattern = AddressValidator.getAddressFromStringPattern(type);
if (addressPattern == null) {
printV('Unknown pattern for $type');
return null;
}
if (requireSurroundingWhitespaces) addressPattern = "$BEFORE_REGEX$addressPattern$AFTER_REGEX";
final text = stripHtmlTags(raw);
final match = RegExp(addressPattern, multiLine: true, caseSensitive: false).firstMatch(text);
if (match == null) return null;
return match.group(0)?.replaceAllMapped(RegExp('[^0-9a-zA-Z]|bitcoincash:|nano_|ban_'),
(Match match) {
String group = match.group(0)!;
if (group.startsWith('bitcoincash:') ||
group.startsWith('nano_') ||
group.startsWith('ban_')) {
return group;
}
return '';
});
}
static bool isEmailFormat(String address) {
final RegExp emailRegex = RegExp(
r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
caseSensitive: false,
);
return emailRegex.hasMatch(address);
}
static String stripHtmlTags(String value) {
return value
.replaceAll(RegExp(r'[\u2028\u2029]'), '\n')
.replaceAll(RegExp(r'<br\s*/?>', caseSensitive: false), '\n')
.replaceAll(RegExp(r'</p\s*>', caseSensitive: false), '\n')
.replaceAll(RegExp(r'<[^>]*>'), ' ')
.trim();
}
}
@@ -0,0 +1,87 @@
import 'package:hash_wallet/core/address_validator.dart';
import 'package:cw_core/crypto_currency.dart';
enum AddressSource {
twitter(label: 'X', iconPath: 'assets/new-ui/address_sources/x.svg', alias: '@username'),
unstoppableDomains(
label: 'Unstoppable Domains',
iconPath: 'assets/new-ui/address_sources/unstoppable.svg',
alias: 'domain.tld'),
openAlias(
label: 'OpenAlias',
iconPath: 'assets/new-ui/address_sources/openalias.svg',
alias: 'name.domain.tld'),
yatRecord(label: 'Yat', iconPath: 'assets/new-ui/address_sources/yat.svg', alias: '🎂🎂🎂'),
fio(label: 'FIO', iconPath: 'assets/new-ui/address_sources/fio.svg', alias: 'user@domain'),
ens(
label: 'Ethereum Name Service',
iconPath: 'assets/new-ui/address_sources/ens.svg',
alias: 'domain.eth'),
mastodon(
label: 'Mastodon',
iconPath: 'assets/new-ui/address_sources/mastodon.svg',
alias: '@username@domain.tld'),
nostr(
label: 'Nostr',
iconPath: 'assets/new-ui/address_sources/nostr.svg',
alias: 'user@domain.tld'),
thorChain(
label: 'ThorChain',
iconPath: 'assets/new-ui/address_sources/icon-thorchain.svg',
alias: 'name'),
wellKnown(
label: '.wellknown',
iconPath: 'assets/new-ui/address_sources/wellknown.svg',
alias: 'domain.tld'),
zanoAlias(
label: 'Zano Alias', iconPath: 'assets/new-ui/address_sources/zano.svg', alias: '@alias'),
bip353(
label: 'BIP353',
iconPath: 'assets/new-ui/address_sources/bip353.svg',
alias: 'user@domain.com'),
zcashAddress(
label: 'Zcash.me',
iconPath: 'assets/new-ui/address_sources/icon-zcash-me.svg',
alias: 'zcash.me/username'),
zcashName(
label: 'Zcash Names',
iconPath: 'assets/new-ui/address_sources/icon-zcashnames.svg',
alias: 'name.zec'),
lnurlPay(
label: 'LNURL Pay',
iconPath: 'assets/new-ui/address_sources/icon-lnurl-pay.svg',
alias: 'user@domain.com'),
contact(label: 'Contact', iconPath: ''),
notParsed(label: 'Unknown', iconPath: ''),
;
const AddressSource({
required this.label,
required this.iconPath,
this.alias = '',
});
final String label;
final String iconPath;
final String alias;
}
extension AddressSourceIndex on AddressSource {
int get raw => index;
static AddressSource fromRaw(int raw) =>
AddressSource.values[raw.clamp(0, AddressSource.values.length - 1)];
}
extension AddressSourceNameParser on AddressSource {
static AddressSource fromLabel(String? text) {
if (text == null || text.trim().isEmpty) {
return AddressSource.notParsed;
}
final needle = text.trim().toLowerCase();
return AddressSource.values.firstWhere(
(src) => src.label.toLowerCase() == needle,
orElse: () => AddressSource.notParsed,
);
}
}
@@ -0,0 +1,70 @@
import 'package:hash_wallet/core/address_resolver/address_lookup_provider.dart';
import 'package:hash_wallet/core/address_resolver/address_sources.dart';
import 'package:hash_wallet/core/address_resolver/bip_353/bip_353_record.dart';
import 'package:hash_wallet/core/address_resolver/parsed_address.dart';
import 'package:hash_wallet/store/settings_store.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/wallet_base.dart';
class Bip353AddressProvider extends AddressLookupProvider {
@override
AddressSource get source => AddressSource.bip353;
@override
List<CryptoCurrency> get supportedCurrencies => [CryptoCurrency.btc];
@override
bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsBip353;
@override
bool canHandle(String query) {
final value = query.trim();
return value.contains('@') && value.contains('.') && !value.startsWith('@');
}
@override
Future<List<ParsedAddress>> resolve({
required String query,
required List<CryptoCurrency> currencies,
required WalletBase wallet,
}) async {
try {
final result = <CryptoCurrency, String>{};
String? dnsProof;
for (final cur in currencies) {
final bip353AddressMap = await Bip353Record.fetchUriByCryptoCurrency(query, cur.title);
if (bip353AddressMap == null || bip353AddressMap.isEmpty) continue;
final spAddress = bip353AddressMap['sp'];
final address = bip353AddressMap['address'];
final chosenAddress = spAddress?.isNotEmpty == true ? spAddress : address;
if (chosenAddress != null && chosenAddress.isNotEmpty) {
result[cur] = chosenAddress;
}
}
if (result.isEmpty) return [];
try {
dnsProof = await Bip353Record.fetchDnsProof(query);
} catch (e) {
printV('Bip353Record.fetchDnsProof error: $e');
}
return [
ParsedAddress(
parsedAddressByCurrencyMap: result,
addressSource: AddressSource.bip353,
handle: query,
bip353DnsProof: dnsProof ?? '',
)
];
} catch (e) {
printV('[address resolver] Error resolving BIP353 address: $e');
return [];
}
}
}
@@ -0,0 +1,102 @@
import 'dart:convert';
import 'dart:isolate';
import 'package:basic_utils/basic_utils.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:dnssec_proof/dnssec_proof.dart';
class Bip353Record {
Bip353Record({
required this.uri,
required this.domain,
});
final String uri;
final String domain;
static const Map<String, String> keyDisplayMap = {
'lno': 'BOLT 12 Offer',
'sp': 'Silent Payment',
'address': 'On-Chain Address',
};
static Future<String?> fetchDnsProof(String bip353Name) async {
if (bip353Name.startsWith('₿')) {
bip353Name = bip353Name.substring(1);
}
final parts = bip353Name.split('@');
if (parts.length != 2) return null;
final userPart = parts[0];
final domainPart = parts[1];
final bip353Domain = '$userPart.user._bitcoin-payment.$domainPart.';
final proof = await Isolate.run(() => DnsProver.getTxtProof(bip353Domain));
return base64.encode(proof);
}
static Future<Map<String, String>?> fetchUriByCryptoCurrency(
String bip353Name, String asset) async {
try {
// 1. Parse the user and domain from "user@domain"
final parts = bip353Name.split('@');
if (parts.length != 2) return null;
String userPart = parts[0];
if (userPart.startsWith('₿')) {
userPart = userPart.substring(1);
}
final domainPart = parts[1];
// 2. Construct the correct subdomain: "user._bitcoin-payment.domain"
final bip353Domain = '$userPart.user._bitcoin-payment.$domainPart';
// 3. Lookup the TXT record with DNSSEC
final txtRecords = await DnsUtils.lookupRecord(
bip353Domain,
RRecordType.TXT,
dnssec: true,
);
final proof = await fetchDnsProof(bip353Name);
if (proof == null) {
throw Exception('DNSSEC proof not found');
}
if (txtRecords == null) return null;
final assetName = CryptoCurrency.fromString(asset).fullName;
if (assetName == null) throw Exception('Invalid asset name');
final formattedAssetName = assetName.toLowerCase().replaceAll(' ', '') + ':';
for (final record in txtRecords) {
final data = record.data.replaceAll('"', '');
if (data.startsWith(formattedAssetName)) {
return _parseAssetUri(data, formattedAssetName);
}
}
} catch (e) {
printV('BIP353Record.fetchBitcoinUri error: $e');
}
return null;
}
static Map<String, String>? _parseAssetUri(String fullUri, String prefix) {
final afterPrefix = fullUri.substring(prefix.length);
if (afterPrefix.isEmpty) return null;
final questionIndex = afterPrefix.indexOf('?');
if (questionIndex == -1) {
return {'address': afterPrefix};
} else {
final addressPart = afterPrefix.substring(0, questionIndex);
final queryPart = afterPrefix.substring(questionIndex + 1);
final result = <String, String>{};
if (addressPart.isNotEmpty) result['address'] = addressPart;
final queryMap = Uri.splitQueryString(queryPart);
result.addAll(queryMap);
return result;
}
}
}
@@ -0,0 +1,48 @@
import 'package:hash_wallet/core/address_resolver/address_lookup_provider.dart';
import 'package:hash_wallet/core/address_resolver/address_sources.dart';
import 'package:hash_wallet/core/address_resolver/ens/ens_record.dart';
import 'package:hash_wallet/core/address_resolver/parsed_address.dart';
import 'package:hash_wallet/store/settings_store.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/wallet_base.dart';
class EnsAddressProvider extends AddressLookupProvider {
@override
AddressSource get source => AddressSource.ens;
@override
List<CryptoCurrency> get supportedCurrencies => [CryptoCurrency.eth];
@override
bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsENS;
@override
bool canHandle(String query) => query.endsWith('.eth'); // ENS handle example: name.eth
@override
Future<List<ParsedAddress>> resolve({
required String query,
required List<CryptoCurrency> currencies,
required WalletBase wallet,
}) async {
final Map<CryptoCurrency, String> result = {};
for (final cur in currencies) {
final address = await EnsRecord.fetchEnsAddress(query, cur, wallet: wallet);
if (address.isNotEmpty && address != "0x0000000000000000000000000000000000000000") {
result[cur] = address;
}
}
if (result.isNotEmpty) {
return [
ParsedAddress(
parsedAddressByCurrencyMap: result,
addressSource: AddressSource.ens,
handle: query,
)
];
}
return [];
}
}
@@ -0,0 +1,68 @@
import "package:hash_wallet/evm/evm.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:ens_dart/ens_dart.dart";
import "package:web3dart/web3dart.dart";
class EnsRecord {
static Future<String> fetchEnsAddress(
String name,
CryptoCurrency cur, {
WalletBase? wallet,
}) async {
Web3Client? _client;
if (wallet?.type == WalletType.ethereum || wallet?.type == WalletType.polygon) {
_client = evm!.getWeb3Client(wallet!);
}
_client ??= Web3Client(
"https://ethereum-rpc.publicnode.com",
ProxyWrapper().getHttpIOClient(),
);
final registryEns = Ens(client: _client).withName(name);
final coinType = getEnsCoinType(cur);
if (coinType == null) {
printV("Unsupported currency for ENS: $cur");
return "";
}
try {
// Check if the ENS name has its resolver set. If not, return an empty string.
final resolverAddress = await registryEns.getResolverAddress(registryEns.nodeHash);
if (isZeroAddress(resolverAddress)) {
printV("No resolver set for ENS name: $name");
return "";
}
final resolverEns = Ens(client: _client,address: resolverAddress).withName(name);
if (coinType == CoinType.ETH) {
return (await resolverEns.getAddress()).hex;
} else {
return await resolverEns.getCoinAddress(coinType);
}
} catch (e) {
printV(e);
return "";
}
}
static bool isZeroAddress(EthereumAddress address) =>
address.addressBytes.every((byte) => byte == 0);
static CoinType? getEnsCoinType(CryptoCurrency cur) => switch (cur) {
CryptoCurrency.xmr => CoinType.XMR,
CryptoCurrency.btc => CoinType.BTC,
CryptoCurrency.ltc => CoinType.LTC,
CryptoCurrency.eth => CoinType.ETH,
CryptoCurrency.maticpoly => CoinType.MATIC,
_ => null,
};
}
@@ -0,0 +1,125 @@
import 'dart:convert';
import 'package:hash_wallet/core/address_resolver/address_lookup_provider.dart';
import 'package:hash_wallet/core/address_resolver/address_sources.dart';
import 'package:hash_wallet/core/address_resolver/parsed_address.dart';
import 'package:hash_wallet/core/address_validator.dart';
import 'package:hash_wallet/store/settings_store.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';
class FioAddressProvider extends AddressLookupProvider {
@override
AddressSource get source => AddressSource.fio;
@override
List<CryptoCurrency> get supportedCurrencies => AddressValidator.reliableValidateCurrencies;
@override
bool canHandle(String q) =>
!q.startsWith('@') &&
q.contains('@') &&
!q.contains('.'); // FIO handle example: username@domain
@override
bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsFio;
@override
Future<List<ParsedAddress>> resolve({
required String query,
required List<CryptoCurrency> currencies,
required WalletBase wallet,
}) async {
final Map<CryptoCurrency, String> result = {};
try {
final bool isFioRegistered = await FioAddressProvider.checkAvail(query);
if (!isFioRegistered) return [];
for (final cur in currencies) {
final address = await FioAddressProvider.getPubAddress(query, cur.title);
if (address != null && address.isNotEmpty) {
result[cur] = address;
}
}
if (result.isNotEmpty) {
return [
ParsedAddress(
parsedAddressByCurrencyMap: result, addressSource: AddressSource.fio, handle: query)
];
}
return [];
} catch (e) {
printV('FioAddressProvider.resolve error: $e');
return [];
}
}
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),
);
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
if (response.statusCode == 400) {
final error = responseJSON['error'] as String;
final message = responseJSON['message'] as String;
printV('${error}\n$message');
return null;
}
if (response.statusCode != 200) {
final String message = responseJSON['message'] as String? ?? 'Unknown error';
printV('Error fetching public address for token $token: $message');
return null;
}
final String pubAddress = responseJSON['public_address'] as String? ?? '';
if (pubAddress.isNotEmpty) {
return pubAddress;
}
return null;
}
}
@@ -0,0 +1,67 @@
import 'package:hash_wallet/core/address_resolver/address_lookup_provider.dart';
import 'package:hash_wallet/core/address_resolver/address_sources.dart';
import 'package:hash_wallet/core/address_resolver/lnurl_pay/lnurlpay_record.dart';
import 'package:hash_wallet/core/address_resolver/parsed_address.dart';
import 'package:hash_wallet/store/settings_store.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/wallet_base.dart';
import 'package:cw_core/wallet_type.dart';
class LNUrlPayAddressProvider extends AddressLookupProvider {
@override
AddressSource get source => AddressSource.lnurlPay;
@override
List<CryptoCurrency> get supportedCurrencies => [CryptoCurrency.btcln];
@override
bool canHandle(String q) =>
q.contains('.') && q.contains('@'); // LNURL-pay handle example: user@domain.com
@override
bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsLNUrl;
@override
Future<List<ParsedAddress>> resolve({
required String query,
required List<CryptoCurrency> currencies,
required WalletBase wallet,
}) async {
try {
if (wallet.type != WalletType.bitcoin) return [];
final formattedName = query.trim();
if (formattedName.isEmpty) return [];
final result = <CryptoCurrency, String>{};
for (final currency in currencies) {
if (!supportedCurrencies.contains(currency)) continue;
final record = await LNUrlPayRecord.fetchAddressAndName(
formattedName: formattedName,
currency: currency,
);
if (record != null && record.address.isNotEmpty) {
result[currency] = record.address;
}
}
if (result.isEmpty) return [];
return [
ParsedAddress(
parsedAddressByCurrencyMap: result,
addressSource: AddressSource.lnurlPay,
handle: formattedName,
profileName: formattedName,
),
];
} catch (e) {
printV('[address resolver] Error resolving LNURL-pay address: $e');
return [];
}
}
}
@@ -0,0 +1,76 @@
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/lnurl.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/utils/proxy_wrapper.dart';
class LNUrlPayRecord {
LNUrlPayRecord({
required this.address,
required this.name,
});
final String name;
final String address;
static Future<String?> checkWellKnownUsername(String username, CryptoCurrency currency,
{bool allowRethrow = false}) async {
if (![CryptoCurrency.btc, CryptoCurrency.btcln].contains(currency)) return null;
// split the string by the @ symbol:
try {
final List<String> splitStrs = username.split("@");
String name = splitStrs.first.toLowerCase();
final String domain = splitStrs.last;
if (splitStrs.length == 3) {
// for username like @alice@domain.org instead of alice@domain.org
name = splitStrs[1];
}
if (name.isEmpty) {
name = "_";
}
final expectedUrl = "https://$domain/.well-known/lnurlp/$name";
final response = await ProxyWrapper().get(
clearnetUri: Uri.parse(expectedUrl),
headers: <String, String>{"Accept": "application/json"},
);
if (response.statusCode == 200) {
return LNURL.encode(expectedUrl);
}
} catch (e) {
if (allowRethrow) rethrow;
printV("error checking well-known username: $e");
}
return null;
}
static String formatDomainName(String name) {
String formattedName = name;
if (name.contains("@")) {
formattedName = name.replaceAll("@", ".");
}
return formattedName;
}
static Future<LNUrlPayRecord?> fetchAddressAndName({
required String formattedName,
required CryptoCurrency currency,
}) async {
final name = formattedName;
printV("formattedName: $formattedName");
final address = await checkWellKnownUsername(formattedName, currency);
if (address == null) {
return null;
}
return LNUrlPayRecord(address: address, name: name);
}
}
@@ -0,0 +1,93 @@
import 'package:hash_wallet/core/address_resolver/address_lookup_provider.dart';
import 'package:hash_wallet/core/address_resolver/address_resolver_utils.dart';
import 'package:hash_wallet/core/address_resolver/address_sources.dart';
import 'package:hash_wallet/core/address_resolver/mastodon/mastodon_api.dart';
import 'package:hash_wallet/core/address_resolver/parsed_address.dart';
import 'package:hash_wallet/core/address_validator.dart';
import 'package:hash_wallet/store/settings_store.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/wallet_base.dart';
class MastodonAddressProvider extends AddressLookupProvider {
@override
AddressSource get source => AddressSource.mastodon;
@override
List<CryptoCurrency> get supportedCurrencies => AddressValidator.reliableValidateCurrencies;
@override
bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsMastodon;
@override
bool canHandle(String query) =>
query.startsWith('@') &&
query.contains('@', 1) &&
query.contains('.', 1); // Mastodon handle example: @username@hostname.xxx
@override
Future<List<ParsedAddress>> resolve({
required String query,
required List<CryptoCurrency> currencies,
required WalletBase wallet,
}) async {
try {
final subText = query.substring(1); // Remove '@' from the beginning of the handle
final hostNameIndex = subText.indexOf('@');
if (hostNameIndex <= 0 || hostNameIndex == subText.length - 1) return [];
final userName = subText.substring(0, hostNameIndex);
final hostName = subText.substring(hostNameIndex + 1);
final result = <CryptoCurrency, String>{};
final mastodonUser = await MastodonAPI.lookupUserByUserName(
userName: userName,
apiHost: hostName,
);
if (mastodonUser == null) return [];
final bio = AddressResolverUtils.stripHtmlTags(mastodonUser.note);
result.addAll(
AddressResolverUtils.extractAddressesFromText(
raw: bio,
currencies: currencies,
),
);
final pinnedPosts = await MastodonAPI.getPinnedPosts(
userId: mastodonUser.id,
apiHost: hostName,
);
if (pinnedPosts.isNotEmpty) {
final pinnedPostsText = pinnedPosts
.map((item) => AddressResolverUtils.stripHtmlTags(item.content))
.where((text) => text.isNotEmpty)
.join('\n');
result.addAll(
AddressResolverUtils.extractAddressesFromText(
raw: pinnedPostsText,
currencies: currencies,
),
);
}
if (result.isEmpty) return [];
return [
ParsedAddress(
parsedAddressByCurrencyMap: result,
addressSource: AddressSource.mastodon,
handle: query,
profileImageUrl: mastodonUser.profileImageUrl,
profileName: mastodonUser.username,
),
];
} catch (e) {
printV('[address resolver] Error resolving Mastodon address: $e');
return [];
}
}
}
@@ -0,0 +1,64 @@
import 'dart:convert';
import 'package:hash_wallet/core/address_resolver/mastodon/mastodon_user.dart';
import 'package:cw_core/utils/proxy_wrapper.dart';
import 'package:cw_core/utils/print_verbose.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;
}
}
}
@@ -0,0 +1,38 @@
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,
);
}
}
@@ -0,0 +1,66 @@
import 'package:hash_wallet/core/address_resolver/address_lookup_provider.dart';
import 'package:hash_wallet/core/address_resolver/address_resolver_utils.dart';
import 'package:hash_wallet/core/address_resolver/address_sources.dart';
import 'package:hash_wallet/core/address_resolver/nostr/nostr_api.dart';
import 'package:hash_wallet/core/address_resolver/parsed_address.dart';
import 'package:hash_wallet/core/address_validator.dart';
import 'package:hash_wallet/store/settings_store.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/wallet_base.dart';
class NostrAddressProvider extends AddressLookupProvider {
@override
AddressSource get source => AddressSource.nostr;
@override
List<CryptoCurrency> get supportedCurrencies => AddressValidator.reliableValidateCurrencies;
@override
bool canHandle(String q) =>
AddressResolverUtils.isEmailFormat(q); // Nostr handle example: username@domain
@override
bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsNostr;
@override
Future<List<ParsedAddress>> resolve({
required String query,
required List<CryptoCurrency> currencies,
required WalletBase wallet,
}) async {
try {
final profile = await NostrProfileHandler.queryProfile(query);
if (profile == null) return [];
final data = await NostrProfileHandler.processRelays(profile, query);
if (data == null) return [];
final result = <CryptoCurrency, String>{};
String queryTxt = data.about;
for (final cur in currencies) {
final addr = AddressResolverUtils.extractAddressByType(raw: queryTxt, type: cur);
if (addr != null && addr.isNotEmpty) {
result[cur] = addr;
queryTxt = queryTxt.replaceFirst(addr, '');
}
}
if (result.isEmpty) return [];
return [
ParsedAddress(
parsedAddressByCurrencyMap: result,
addressSource: AddressSource.nostr,
handle: query,
profileImageUrl: data.picture,
profileName: data.name,
)
];
} catch (e) {
printV('Error looking up Nostr profile: $e');
return [];
}
}
}
@@ -0,0 +1,127 @@
import 'dart:async';
import 'dart:convert';
import 'package:hash_wallet/core/address_resolver/nostr/nostr_user.dart';
import 'package:nostr_tools/nostr_tools.dart';
import 'dart:async' show Completer, TimeoutException, runZonedGuarded;
class NostrProfileHandler {
static final relayToDomainMap = {
'relay.snort.social': 'snort.social',
};
static final Nip05 _nip05 = Nip05();
static Future<ProfilePointer?> queryProfile(String nip05Address) async {
final profile = await _nip05.queryProfile(nip05Address);
if (profile?.pubkey != null && profile?.relays?.isNotEmpty == true) {
return profile;
}
return null;
}
static Future<UserMetadata?> processRelays(
ProfilePointer profile,
String nip05Address,
) async {
final userDomain = _extractDomain(nip05Address);
const int metaKind = 0;
// Domain-matched relays first
for (final String relayUrl in profile.relays ?? []) {
final relayDomain =
relayToDomainMap[_getDomainFromRelayUrl(relayUrl)] ?? _getDomainFromRelayUrl(relayUrl);
if (relayDomain == userDomain) {
final data = await _fetchInfoFromRelay(relayUrl, profile.pubkey, [metaKind]);
if (data != null) return data;
}
}
// Then try every remaining relay
for (final String relayUrl in profile.relays ?? []) {
final data = await _fetchInfoFromRelay(relayUrl, profile.pubkey, [metaKind]);
if (data != null) return data;
}
// Nothing found
return null;
}
static const Duration _relayTimeout = Duration(seconds: 3);
static Future<UserMetadata?> _fetchInfoFromRelay(
String relayUrl, String userPubKey, List<int> kinds) async {
// sanitize so obvious junk (like '#') doesn't reach connect()
final clean = _sanitizeRelay(relayUrl);
if (clean.isEmpty) return null;
final result = Completer<UserMetadata?>();
runZonedGuarded(() async {
try {
final relay = RelayApi(relayUrl: clean);
final stream = await relay.connect().timeout(
_relayTimeout,
onTimeout: () {
relay.close();
throw TimeoutException('Relay connect timeout');
},
);
relay.sub([
Filter(kinds: kinds, authors: [userPubKey])
]);
final sub = stream.listen((msg) {
if (msg.type == 'EVENT' && !result.isCompleted) {
try {
final event = msg.message as Event;
final jsonMap = json.decode(event.content) as Map<String, dynamic>;
result.complete(UserMetadata.fromJson(jsonMap));
} catch (_) {
if (!result.isCompleted) result.complete(null);
}
}
}, onError: (_) {
if (!result.isCompleted) result.complete(null);
}, onDone: () {
if (!result.isCompleted) result.complete(null);
});
final value = await result.future.timeout(_relayTimeout, onTimeout: () => null);
await sub.cancel();
relay.close();
if (!result.isCompleted) result.complete(value);
} catch (_) {
if (!result.isCompleted) result.complete(null);
}
}, (error, stack) {
// swallow ALL async errors from the websocket layer (including "was not upgraded to websocket")
if (!result.isCompleted) result.complete(null);
});
return result.future;
}
static String _sanitizeRelay(String url) {
url = url.replaceFirst(RegExp(r'^https?://'), 'wss://');
final uri = Uri.parse(url);
return Uri(
scheme: uri.scheme.isEmpty ? 'wss' : uri.scheme,
host: uri.host,
port: uri.hasPort ? uri.port : 443,
).toString();
}
static String _extractDomain(String nip05) => nip05.split('@').last;
static String _getDomainFromRelayUrl(String url) {
try {
return Uri.parse(url).host;
} catch (_) {
return '';
}
}
}
@@ -0,0 +1,34 @@
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? ?? '',
);
}
}
@@ -0,0 +1,64 @@
import 'package:hash_wallet/core/address_resolver/address_lookup_provider.dart';
import 'package:hash_wallet/core/address_resolver/address_sources.dart';
import 'package:hash_wallet/core/address_resolver/openalias/openalias_record.dart';
import 'package:hash_wallet/core/address_resolver/parsed_address.dart';
import 'package:hash_wallet/store/settings_store.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/wallet_base.dart';
class OpenaliasAddressProvider extends AddressLookupProvider {
@override
AddressSource get source => AddressSource.openAlias;
@override
List<CryptoCurrency> get supportedCurrencies => [CryptoCurrency.xmr, CryptoCurrency.btc];
@override
bool canHandle(String q) {
final formattedName = OpenaliasRecord.formatDomainName(q);
return formattedName.contains(".");
}
@override
bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsOpenAlias;
@override
Future<List<ParsedAddress>> resolve({
required String query,
required List<CryptoCurrency> currencies,
required WalletBase wallet,
}) async {
try {
final formatted = OpenaliasRecord.formatDomainName(query);
final txtRecords = await OpenaliasRecord.lookupOpenAliasRecord(formatted);
if (txtRecords == null) return [];
final result = <CryptoCurrency, String>{};
for (final cur in currencies) {
final rec = OpenaliasRecord.fetchAddressAndName(
formattedName: formatted,
ticker: cur.title.toLowerCase(),
txtRecord: txtRecords,
);
if (rec.address.isNotEmpty) result[cur] = rec.address;
}
if (result.isEmpty) return [];
return [
ParsedAddress(
parsedAddressByCurrencyMap: result,
addressSource: AddressSource.openAlias,
handle: query,
)
];
} catch (e) {
printV('[address resolver] Error resolving OpenAlias address: $e');
return [];
}
}
}
@@ -0,0 +1,69 @@
import 'package:basic_utils/basic_utils.dart';
import 'package:cw_core/utils/print_verbose.dart';
class OpenaliasRecord {
OpenaliasRecord({
required this.address,
required this.name,
required this.description,
});
final String name;
final String address;
final String description;
static String formatDomainName(String name) {
String formattedName = name;
if (name.contains("@")) {
formattedName = name.replaceAll("@", ".");
}
return formattedName;
}
static Future<List<RRecord>?> lookupOpenAliasRecord(String name) async {
try {
final txtRecord = await DnsUtils.lookupRecord(name, RRecordType.TXT, dnssec: true);
return txtRecord;
} catch (e) {
printV("${e.toString()}");
return null;
}
}
static OpenaliasRecord fetchAddressAndName({
required String formattedName,
required String ticker,
required List<RRecord> txtRecord,
}) {
var address = '';
var name = formattedName;
var note = '';
final addrRe = RegExp(r'recipient_address=([^;\s]+)', caseSensitive: false);
final nameRe = RegExp(r'recipient_name=([^;]+)', caseSensitive: false);
final noteRe = RegExp(r'tx_description=([^;]+)', caseSensitive: false);
final tag = 'oa1:$ticker';
for (final rr in txtRecord) {
final txt = rr.data.replaceAll('"', '');
if (!txt.toLowerCase().contains(tag)) continue;
final addrM = addrRe.firstMatch(txt);
if (addrM != null) address = addrM.group(1)!;
final nameM = nameRe.firstMatch(txt);
if (nameM != null) name = nameM.group(1)!.trim();
final noteM = noteRe.firstMatch(txt);
if (noteM != null) note = noteM.group(1)!.trim();
break;
}
return OpenaliasRecord(address: address, name: name, description: note);
}
}
@@ -0,0 +1,22 @@
import 'package:hash_wallet/core/address_resolver/address_sources.dart';
import 'package:cw_core/crypto_currency.dart';
class ParsedAddress {
const ParsedAddress({
required this.parsedAddressByCurrencyMap,
this.addressSource = AddressSource.notParsed,
this.handle = '',
this.profileImageUrl = '',
this.profileName = '',
this.description = '',
this.bip353DnsProof,
});
final Map<CryptoCurrency, String> parsedAddressByCurrencyMap;
final AddressSource addressSource;
final String handle;
final String profileImageUrl;
final String profileName;
final String description;
final String? bip353DnsProof;
}
@@ -0,0 +1,96 @@
import 'package:hash_wallet/core/address_resolver/address_lookup_provider.dart';
import 'package:hash_wallet/core/address_resolver/address_resolver_utils.dart';
import 'package:hash_wallet/core/address_resolver/address_sources.dart';
import 'package:hash_wallet/core/address_resolver/parsed_address.dart';
import 'package:hash_wallet/core/address_resolver/twitter/twitter_api.dart';
import 'package:hash_wallet/core/address_resolver/twitter/twitter_user.dart';
import 'package:hash_wallet/core/address_validator.dart';
import 'package:hash_wallet/store/settings_store.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/wallet_base.dart';
class TwitterAddressProvider extends AddressLookupProvider {
@override
AddressSource get source => AddressSource.twitter;
@override
List<CryptoCurrency> get supportedCurrencies => AddressValidator.reliableValidateCurrencies;
@override
bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsTwitter;
@override
bool canHandle(String query) =>
query.startsWith('@') && !query.substring(1).contains('@'); // x handle example: @username
/// Resolves a Twitter handle to a list of ParsedAddress objects by extracting potential cryptocurrency addresses
/// from the user's, profile.
/// fallback to resolving Unstoppable Domains if no addresses are found in each of the Twitter user's bio, location, and pinned tweet.
@override
Future<List<ParsedAddress>> resolve({
required String query,
required List<CryptoCurrency> currencies,
required WalletBase wallet,
}) async {
final formattedName = query.replaceFirst("@", "");
try {
final result = <CryptoCurrency, String>{};
final twitterUser = await TwitterApi.lookupUserByName(userName: formattedName);
final bio = (text: twitterUser.description, whitespaces: true);
final location = (text: twitterUser.location, whitespaces: false);
final pinnedTweet = (text: twitterUser.pinnedTweet?.text ?? '', whitespaces: true);
final twitterTexts = [
bio,
location,
pinnedTweet,
].where((item) => item.text.isNotEmpty);
for (final item in twitterTexts) {
result.addAll(
AddressResolverUtils.extractAddressesFromText(
raw: item.text,
currencies: currencies,
requireSurroundingWhitespaces: item.whitespaces,
),
);
if (result.isEmpty) {
final domainResult = await AddressResolverUtils.resolveUnstoppableDomainFromText(
raw: item.text,
currencies: currencies,
);
if (domainResult.isNotEmpty) {
return [_buildTwitterParsedAddress(query, twitterUser, domainResult)];
}
}
}
if (result.isEmpty) return [];
return [_buildTwitterParsedAddress(query, twitterUser, result)];
} catch (e) {
printV('[address resolver service] Twitter error: $e');
return [];
}
}
ParsedAddress _buildTwitterParsedAddress(
String query,
TwitterUser twitterUser,
Map<CryptoCurrency, String> result,
) {
return ParsedAddress(
parsedAddressByCurrencyMap: result,
addressSource: AddressSource.twitter,
handle: query,
profileImageUrl: twitterUser.profileImageUrl,
profileName: twitterUser.name,
);
}
}
@@ -0,0 +1,67 @@
import 'dart:convert';
import 'package:hash_wallet/.secrets.g.dart' as secrets;
import 'package:hash_wallet/core/address_resolver/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;
}
}
}
@@ -0,0 +1,49 @@
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,
);
}
}
@@ -0,0 +1,237 @@
import 'dart:convert';
import 'package:hash_wallet/core/address_resolver/address_lookup_provider.dart';
import 'package:hash_wallet/core/address_resolver/address_sources.dart';
import 'package:hash_wallet/core/address_resolver/openalias/openalias_record.dart';
import 'package:hash_wallet/core/address_resolver/parsed_address.dart';
import 'package:hash_wallet/store/settings_store.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';
class UnstoppableAddressProvider extends AddressLookupProvider {
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"
];
@override
AddressSource get source => AddressSource.unstoppableDomains;
@override
List<CryptoCurrency> get supportedCurrencies => [CryptoCurrency.xmr, CryptoCurrency.btc];
@override
bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsUnstoppableDomains;
@override
bool canHandle(String query) {
// Unstoppable Domains handle example: name.crypto
final formattedName = OpenaliasRecord.formatDomainName(query);
final domainParts = formattedName.split('.');
final name = domainParts.last;
return domainParts.length > 1 &&
domainParts.first.isNotEmpty &&
name.isNotEmpty &&
unstoppableDomains.any((domain) => name.trim() == domain);
}
@override
Future<List<ParsedAddress>> resolve({
required String query,
required List<CryptoCurrency> currencies,
required WalletBase wallet,
}) async {
try {
final formattedName = OpenaliasRecord.formatDomainName(query);
final result = <CryptoCurrency, String>{};
for (final currency in currencies) {
final address = await fetchUnstoppableDomainAddress(
formattedName,
currency.title,
);
if (address.isNotEmpty) {
result[currency] = address;
}
}
if (result.isEmpty) return [];
return [
ParsedAddress(
parsedAddressByCurrencyMap: result,
profileName: formattedName,
addressSource: AddressSource.unstoppableDomains,
handle: formattedName,
),
];
} catch (e) {
printV('[address resolver] Error resolving Unstoppable Domain: $e');
return [];
}
}
static 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;
}
}
@@ -0,0 +1,49 @@
import 'package:hash_wallet/core/address_resolver/address_lookup_provider.dart';
import 'package:hash_wallet/core/address_resolver/address_sources.dart';
import 'package:hash_wallet/core/address_resolver/parsed_address.dart';
import 'package:hash_wallet/core/address_resolver/wellknown/wellknown_record.dart';
import 'package:hash_wallet/store/settings_store.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/wallet_base.dart';
class WellKnownAddressProvider extends AddressLookupProvider {
@override
AddressSource get source => AddressSource.wellKnown;
@override
List<CryptoCurrency> get supportedCurrencies => [CryptoCurrency.nano];
@override
bool canHandle(String q) => q.contains('.') && q.contains('@');
// .well-known handle example:
@override
bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsWellKnown;
@override
Future<List<ParsedAddress>> resolve({
required String query,
required List<CryptoCurrency> currencies,
required WalletBase wallet,
}) async {
try {
final rec = await WellKnownRecord.fetch(query, CryptoCurrency.nano);
if (rec == null || rec.address.isEmpty) return [];
return [
ParsedAddress(
parsedAddressByCurrencyMap: {CryptoCurrency.nano: rec.address},
addressSource: AddressSource.wellKnown,
handle: query,
profileName: rec.title ?? '',
profileImageUrl: rec.imageUrl ?? '',
)
];
} catch (e) {
printV('[address resolver] Error resolving Well-Known address: $e');
return [];
}
}
}
@@ -0,0 +1,78 @@
import 'dart:convert';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/utils/proxy_wrapper.dart';
class WellKnownRecord {
WellKnownRecord({
required this.address,
required this.alias,
required this.imageUrl,
required this.title,
});
final String address;
final String alias;
final String? imageUrl;
final String? title;
static const _jsonLocation = {CryptoCurrency.nano: 'nano-currency'};
static Future<WellKnownRecord?> fetch(
String username,
CryptoCurrency currency,
) async {
final location = _jsonLocation[currency];
if (location == null) {
printV('well-known: unsupported coin $currency');
return null;
}
final parts = username.split('@');
if (parts.length < 2) {
printV('well-known: missing @ in "$username"');
return null;
}
final alias = (parts.length == 3 ? parts[1] : parts[0]).trim();
final domain = parts.last.trim();
final encoded = Uri.encodeComponent(alias.isEmpty ? '_' : alias);
final uri = Uri.https(
domain,
'/.well-known/$location.json',
{'names': encoded},
);
try {
final res = await ProxyWrapper().get(
clearnetUri: uri,
headers: {'Accept': 'application/json'},
);
final cType = res.headers['content-type']?.toLowerCase() ?? '';
if (res.statusCode != 200 || !cType.contains('application/json')) {
printV('well-known: $uri → ${res.statusCode} $cType');
return null;
}
final body = json.decode(res.body) as Map<String, dynamic>;
final items = body['names'] as List<dynamic>? ?? const [];
for (final raw in items.whereType<Map>()) {
if (raw['name'].toString().toLowerCase() == alias.toLowerCase()) {
return WellKnownRecord(
address: raw['address']?.toString() ?? '',
alias: alias,
imageUrl: raw['image']?.toString(),
title: raw['name']?.toString(),
);
}
}
printV('well-known: alias "$alias" not found in list');
} catch (e) {
printV('well-known: network / JSON error → $e');
}
return null;
}
}
@@ -0,0 +1,74 @@
import 'dart:convert';
import 'package:hash_wallet/core/address_resolver/address_lookup_provider.dart';
import 'package:hash_wallet/core/address_resolver/address_sources.dart';
import 'package:hash_wallet/core/address_resolver/parsed_address.dart';
import 'package:hash_wallet/store/settings_store.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';
class ZanoAliasAddressProvider extends AddressLookupProvider {
@override
AddressSource get source => AddressSource.zanoAlias;
@override
List<CryptoCurrency> get supportedCurrencies => [CryptoCurrency.zano];
@override
bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsZanoAlias;
@override
bool canHandle(String query) => query.startsWith('@'); // zano handle example: @username
static const _zanoUrl = 'http://37.27.100.59:10500/json_rpc';
@override
Future<List<ParsedAddress>> resolve({
required String query,
required List<CryptoCurrency> currencies,
required WalletBase wallet,
}) async {
try {
if (!currencies.contains(CryptoCurrency.zano)) return [];
final formattedName = query.replaceFirst("@", "");
if (formattedName.isEmpty) return [];
final zanoAddress = await fetchZanoAliasAddress(formattedName);
if (zanoAddress == null || zanoAddress.isEmpty) return [];
return [
ParsedAddress(
parsedAddressByCurrencyMap: {CryptoCurrency.zano: zanoAddress},
addressSource: AddressSource.zanoAlias,
handle: query,
),
];
} catch (e) {
printV('[address resolver] Error resolving Zano Alias: $e');
return [];
}
}
static Future<String?> fetchZanoAliasAddress(String alias) async {
try {
final uri = Uri.parse(_zanoUrl);
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('[address resolver] Zano Alias error: $e');
return null;
}
}
}
@@ -0,0 +1,49 @@
import 'package:hash_wallet/core/address_resolver/address_lookup_provider.dart';
import 'package:hash_wallet/core/address_resolver/address_sources.dart';
import 'package:hash_wallet/core/address_resolver/parsed_address.dart';
import 'package:hash_wallet/core/address_resolver/zcash/zcash_names_record.dart';
import 'package:hash_wallet/store/settings_store.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/wallet_base.dart';
class ZcashNameAddressProvider extends AddressLookupProvider {
@override
AddressSource get source => AddressSource.zcashName;
@override
List<CryptoCurrency> get supportedCurrencies => [CryptoCurrency.zec];
@override
bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsZcashNames;
@override
bool canHandle(String query) {
final lowerText = query.toLowerCase();
return lowerText.endsWith(".zec") || lowerText.endsWith(".zcash");
} // Zcash handle example: name.zec
@override
Future<List<ParsedAddress>> resolve({
required String query,
required List<CryptoCurrency> currencies,
required WalletBase wallet,
}) async {
try {
final address = await ZcashNamesRecord.fetchZcashNamesAddress(query);
if (address == null || address.isEmpty) return [];
return [
ParsedAddress(
parsedAddressByCurrencyMap: {CryptoCurrency.zec: address},
profileName: query,
addressSource: AddressSource.zcashName,
handle: query,
),
];
} catch (e) {
printV('[address resolver] Error resolving Zcash Name: $e');
return [];
}
}
}
@@ -0,0 +1,74 @@
import 'package:hash_wallet/core/address_resolver/address_lookup_provider.dart';
import 'package:hash_wallet/core/address_resolver/address_sources.dart';
import 'package:hash_wallet/core/address_resolver/parsed_address.dart';
import 'package:hash_wallet/store/settings_store.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';
class ZcashMeAddressProvider extends AddressLookupProvider {
@override
AddressSource get source => AddressSource.zcashAddress;
@override
List<CryptoCurrency> get supportedCurrencies => [CryptoCurrency.zec];
@override
bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsZcashAddress;
@override
bool canHandle(String query) {
final value = query.trim().toLowerCase();
return value.startsWith('zcash.me/') && value.split('/').last.isNotEmpty;
} // zcash.me profile example: zcash.me/username
@override
Future<List<ParsedAddress>> resolve({
required String query,
required List<CryptoCurrency> currencies,
required WalletBase wallet,
}) async {
try {
final handle = query.trim().split('/').last;
if (handle.isEmpty) return [];
final address = await _fetchZcashAddress(handle);
if (address == null || address.isEmpty) return [];
return [
ParsedAddress(
parsedAddressByCurrencyMap: {CryptoCurrency.zec: address},
profileName: handle,
addressSource: AddressSource.zcashAddress,
handle: query,
),
];
} catch (e) {
printV('[address resolver] Error resolving zcash.me address: $e');
return [];
}
}
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);
return match?.group(0);
}
} catch (e) {
printV('[address resolver] Error fetching zcash.me profile: $e');
}
return null;
}
}
@@ -0,0 +1,74 @@
import 'dart:async';
import 'dart:convert';
import 'package:hash_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);
}
}
+39 -18
View File
@@ -8,8 +8,11 @@ const BEFORE_REGEX = '(^|\\s)';
const AFTER_REGEX = '(\$|\\s)';
class AddressValidator extends TextValidator {
AddressValidator({required CryptoCurrency type, bool isTestnet = false})
: super(
AddressValidator({
required CryptoCurrency type,
bool isTestnet = false,
String? network,
}) : super(
errorMessage: S.current.error_text_address,
useAdditionalValidation: [CryptoCurrency.btc, CryptoCurrency.ltc].contains(type)
? (String txt) {
@@ -28,11 +31,36 @@ class AddressValidator extends TextValidator {
);
}
: null,
pattern: getPattern(type, isTestnet: isTestnet),
pattern: getPattern(type, isTestnet: isTestnet, network: network),
length: getLength(type),
);
static String getPattern(CryptoCurrency type, {bool isTestnet = false}) {
static const List<CryptoCurrency> reliableValidateCurrencies = [
CryptoCurrency.xmr,
CryptoCurrency.btc,
CryptoCurrency.ltc,
CryptoCurrency.bch,
CryptoCurrency.trx,
CryptoCurrency.nano,
CryptoCurrency.sol,
CryptoCurrency.zano,
CryptoCurrency.ada,
CryptoCurrency.xrp,
CryptoCurrency.xhv,
CryptoCurrency.zaddr,
CryptoCurrency.zec,
CryptoCurrency.dcr,
CryptoCurrency.rvn,
CryptoCurrency.near,
CryptoCurrency.rune,
CryptoCurrency.scrt,
CryptoCurrency.stx,
CryptoCurrency.kmd,
CryptoCurrency.doge,
CryptoCurrency.btcln,
];
static String? getPattern(CryptoCurrency type, {bool isTestnet = false, String? network}) {
var pattern = "";
if (type is Erc20Token) {
pattern = '0x[0-9a-zA-Z]+';
@@ -80,6 +108,7 @@ class AddressValidator extends TextValidator {
case CryptoCurrency.eth:
case CryptoCurrency.baseEth:
case CryptoCurrency.arbEth:
case CryptoCurrency.robEth:
case CryptoCurrency.mana:
case CryptoCurrency.matic:
case CryptoCurrency.maticpoly:
@@ -141,13 +170,7 @@ class AddressValidator extends TextValidator {
case CryptoCurrency.hbar:
pattern = '[0-9a-zA-Z.]+';
case CryptoCurrency.zec:
pattern = '(?:'
't1[0-9A-Za-z]{33}'
'|t3[0-9A-Za-z]{33}'
'|zs[a-z0-9]{76}'
'|zxviews[a-z0-9]{278}'
'|u1[a-z0-9]{1,300}'
')';
pattern = "(?:$zcashAddressPattern|zxviews[a-z0-9]{278})";
case CryptoCurrency.dcr:
pattern = '(D|T|S)[ksecS]([0-9a-zA-Z])+';
case CryptoCurrency.rvn:
@@ -236,6 +259,7 @@ class AddressValidator extends TextValidator {
case CryptoCurrency.avaxc:
case CryptoCurrency.baseEth:
case CryptoCurrency.arbEth:
case CryptoCurrency.robEth:
return [42];
case CryptoCurrency.bch:
return [42, 54, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35];
@@ -314,6 +338,8 @@ class AddressValidator extends TextValidator {
static String get silentPaymentAddressPatternMainnet => 'sp1[0-9a-zA-Z]{113}';
static String get silentPaymentAddressPatternTestnet => '(tsp|sprt)1[0-9a-zA-Z]{113}';
static String get mWebAddressPattern => MwebAddress.regex.pattern;
static String get zcashAddressPattern =>
"t1[0-9A-Za-z]{33}|t3[0-9A-Za-z]{33}|zs[a-z0-9]{76}|u1[a-z0-9]{1,300}";
static const String bolt11InvoiceMatcher = r'^(lightning:)?(lnbc|lntb|lnbs|lnbcrt)[a-z0-9]+$';
static const String bolt12OfferMatcher = r'^(lightning:)?(lno1)[a-z0-9]+$';
@@ -345,6 +371,7 @@ class AddressValidator extends TextValidator {
case CryptoCurrency.maticpoly:
case CryptoCurrency.baseEth:
case CryptoCurrency.arbEth:
case CryptoCurrency.robEth:
case CryptoCurrency.arb:
pattern = '0x[0-9a-zA-Z]+';
case CryptoCurrency.nano:
@@ -360,13 +387,7 @@ class AddressValidator extends TextValidator {
case CryptoCurrency.zano:
pattern = '([1-9A-HJ-NP-Za-km-z]{90,200})|(@[\w\d.-]+)';
case CryptoCurrency.zec:
pattern = '(?:'
't1[0-9A-Za-z]{33}'
'|t3[0-9A-Za-z]{33}'
'|zs[a-z0-9]{76}'
'|zxviews[a-z0-9]{278}'
'|u1[a-z0-9]{1,300}'
')';
pattern = "(?:$zcashAddressPattern|zxviews[a-z0-9]{278})";
default:
if (type.tag == CryptoCurrency.eth.title) {
pattern = '0x[0-9a-zA-Z]+';
+27 -41
View File
@@ -1,7 +1,9 @@
import 'package:hash_wallet/entities/bitcoin_amount_display_mode.dart';
import 'package:hash_wallet/src/screens/wallet_connect/utils/string_parsing.dart';
import 'package:cw_core/amount/money.dart';
import 'package:cw_core/crypto_amount_format.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/currency.dart';
import 'package:cw_core/utils/print_verbose.dart';
class AmountParsingProxy {
@@ -24,7 +26,7 @@ class AmountParsingProxy {
if (useSatoshi(cryptoCurrency) && amount.isNotEmpty) {
return cryptoCurrency
.parseAmount(amount.withMaxDecimals(cryptoCurrency.decimals))
.toString();
.toStringWithPrecision(useBaseUnit: true);
}
return amount.withMaxDecimals(cryptoCurrency.decimals);
@@ -35,55 +37,39 @@ class AmountParsingProxy {
}
}
/// [getCryptoStringRepresentation] turns the input [amount] into the preferred representation of [cryptoCurrency]
String getDisplayCryptoString(int amount, CryptoCurrency cryptoCurrency) {
if (useSatoshi(cryptoCurrency)) {
return "$amount";
}
String getDisplayCryptoString(int amount, CryptoCurrency cryptoCurrency) =>
asDisplayString(Money.fromInt(amount, cryptoCurrency));
return cryptoCurrency.formatAmount(BigInt.from(amount));
}
/// [getDisplayCryptoString] turns the input [amount] into the preferred representation of [cryptoCurrency]
///
/// if [displayMode] is [BitcoinAmountDisplayMode.satoshi] it returns 1 BTC as 100000000
/// if [displayMode] is [BitcoinAmountDisplayMode.bitcoin] it returns 1 BTC as 1
/// hardcoding fractionalDigits to 8 as it's a display string, so no need to show more than that
String asDisplayString(Money amount) =>
amount.toStringWithPrecision(useBaseUnit: useSatoshi(amount.currency), fractionalDigits: 8);
String getDisplayCryptoStringFromBigInt(BigInt amount, CryptoCurrency cryptoCurrency) {
if (useSatoshi(cryptoCurrency)) {
return "$amount";
}
/// [asDisplayStringWithSymbol] turns the input [amount] into the preferred representation of
/// [cryptoCurrency] following the symbol of the unit
///
/// if [displayMode] is [BitcoinAmountDisplayMode.satoshi] it returns 1 BTC as 100000000 sats
/// if [displayMode] is [BitcoinAmountDisplayMode.bitcoin] it returns 1 BTC as 1 BTC
/// hardcoding fractionalDigits to 8 as it's a display string, so no need to show more than that
String asDisplayStringWithSymbol(Money amount) =>
amount.toStringWithSymbol(useBaseUnit: useSatoshi(amount.currency), fractionalDigits: 8);
return cryptoCurrency.formatAmount(amount);
}
/// [parseCryptoString] turns the the display representation [string] into `Money` with [cryptoCurrency]
Money parseCryptoString(String amount, CryptoCurrency cryptoCurrency) =>
Money.parse(amount, cryptoCurrency, isBaseUnit: useSatoshi(cryptoCurrency));
/// [getCryptoStringFromDouble] turns the input [amount] into the preferred representation of [cryptoCurrency] and
String getDisplayCryptoStringFromDouble(double amount, CryptoCurrency cryptoCurrency) {
if (useSatoshi(cryptoCurrency)) {
return "$amount";
}
return cryptoCurrency.formatAmount(BigInt.from(amount));
}
/// [parseCryptoString] turns the input [string] into a `BigInt` presentation of the [cryptoCurrency]
BigInt parseCryptoString(String amount, CryptoCurrency cryptoCurrency) {
if (useSatoshi(cryptoCurrency)) {
return BigInt.parse(amount);
}
return cryptoCurrency.parseAmount(amount);
}
/// [tryParseCryptoString] tries to turn the display representation [string] into a `BigInt` presentation of the [cryptoCurrency]
BigInt? tryParseCryptoString(String amount, CryptoCurrency cryptoCurrency) {
if (useSatoshi(cryptoCurrency)) {
return BigInt.tryParse(amount);
}
return cryptoCurrency.tryParseAmount(amount);
}
/// [tryParseCryptoString] tries to turn the display representation [string] into `Money` with [cryptoCurrency]
Money? tryParseCryptoString(String amount, CryptoCurrency cryptoCurrency) =>
Money.tryParse(amount, cryptoCurrency, isBaseUnit: useSatoshi(cryptoCurrency));
/// [getCryptoSymbol] returns the correct Symbol related to the presentation
String getCryptoSymbol(CryptoCurrency cryptoCurrency) =>
useSatoshi(cryptoCurrency) ? "sats" : cryptoCurrency.title.safeSubString(0, 8);
bool useSatoshi(CryptoCurrency cryptoCurrency) =>
bool useSatoshi(Currency cryptoCurrency) =>
([CryptoCurrency.btc, CryptoCurrency.btcln].contains(cryptoCurrency) &&
displayMode == BitcoinAmountDisplayMode.satoshi) ||
(CryptoCurrency.btcln == cryptoCurrency &&
+174
View File
@@ -0,0 +1,174 @@
import "package:hash_wallet/core/universal_address_detector.dart";
import "package:hash_wallet/utils/payment_request.dart";
import "package:cw_core/amount/money.dart";
import "package:cw_core/crypto_currency.dart";
import "package:cw_core/wallet_info.dart";
import "package:cw_core/wallet_type.dart";
sealed class ChainBinding {
const ChainBinding();
}
class ExplicitEvmChain extends ChainBinding {
const ExplicitEvmChain(this.chainId);
final int chainId;
}
class ChainlessEvm extends ChainBinding {
const ChainlessEvm();
}
class NoEvmBinding extends ChainBinding {
const NoEvmBinding();
}
class AnyPayRequest {
AnyPayRequest({
required this.rawInput,
required this.paymentRequest,
required this.detection,
required this.chainBinding,
});
final String rawInput;
final PaymentRequest paymentRequest;
final AddressDetectionResult detection;
final ChainBinding chainBinding;
String get address => paymentRequest.address;
String get amount => paymentRequest.amount;
String? get contractAddress => paymentRequest.contractAddress;
bool get hasContract => contractAddress != null && contractAddress!.isNotEmpty;
bool get isLightning => detection.detectedCurrency == CryptoCurrency.btcln;
}
class WalletSnapshot {
WalletSnapshot({
required this.type,
required this.currentWalletIsEvm,
required this.currentChainId,
required this.wallets,
required this.supportedEvmChains,
required this.hasEvmProxy,
required this.hasSolanaProxy,
required this.hasTronProxy,
});
final WalletType type;
final bool currentWalletIsEvm;
final int? currentChainId;
final List<WalletInfo> wallets;
final Map<int, WalletType> supportedEvmChains;
final bool hasEvmProxy;
final bool hasSolanaProxy;
final bool hasTronProxy;
List<WalletInfo> walletsOfType(WalletType walletType) =>
wallets.where((wallet) => wallet.type == walletType).toList();
}
sealed class AnyPayResolution {
const AnyPayResolution();
}
class NoTokenRequested extends AnyPayResolution {
const NoTokenRequested();
}
class TokenResolved extends AnyPayResolution {
const TokenResolved({required this.token, this.chainId, this.amountOverride});
final CryptoCurrency token;
final int? chainId;
final String? amountOverride;
}
class TokenUnknown extends AnyPayResolution {
const TokenUnknown(this.contract);
final String contract;
}
sealed class AnyPayDecision {
const AnyPayDecision();
}
class AnyPayApplyToCurrentWallet extends AnyPayDecision {
const AnyPayApplyToCurrentWallet({
this.token,
this.amountOverride,
this.fallbackCurrency,
});
final CryptoCurrency? token;
final String? amountOverride;
final CryptoCurrency? fallbackCurrency;
}
class AnyPayEvmNetworkChoice extends AnyPayDecision {
const AnyPayEvmNetworkChoice();
}
class AnyPayCrossChainPayment extends AnyPayDecision {
const AnyPayCrossChainPayment({
required this.targetWalletType,
required this.wallets,
this.targetChainId,
this.token,
this.amountOverride,
this.canSwap = true,
});
final WalletType targetWalletType;
final int? targetChainId;
final List<WalletInfo> wallets;
final CryptoCurrency? token;
final String? amountOverride;
final bool canSwap;
bool get hasCompatibleWallet => wallets.isNotEmpty;
}
class AnyPayUnsupportedNetwork extends AnyPayDecision {
const AnyPayUnsupportedNetwork(this.chainId);
final int chainId;
}
class AnyPayUnsupportedToken extends AnyPayDecision {
const AnyPayUnsupportedToken(this.contract);
final String contract;
}
class AnyPayEmptyInput extends AnyPayDecision {
const AnyPayEmptyInput();
}
class AnyPayEvaluation {
AnyPayEvaluation({required this.request, required this.decision});
final AnyPayRequest request;
final AnyPayDecision decision;
}
class AnyPaySwapIntent {
AnyPaySwapIntent({
required this.request,
required this.receiveCurrency,
required this.targetWalletType,
this.receiveAmount,
});
final AnyPayRequest request;
final CryptoCurrency receiveCurrency;
final WalletType targetWalletType;
final Money? receiveAmount;
String get recipientAddress => request.address;
}
+71
View File
@@ -0,0 +1,71 @@
import "package:hash_wallet/core/anypay/anypay_models.dart";
import "package:hash_wallet/core/universal_address_detector.dart";
import "package:hash_wallet/utils/payment_request.dart";
import "package:cw_core/crypto_currency.dart";
import "package:cw_core/currency_for_wallet_type.dart";
import "package:cw_core/payment_uris.dart";
class AnyPayParser {
static AnyPayRequest fromRaw(String input) {
final paymentRequest = PaymentRequest.fromString(input);
return AnyPayRequest(
rawInput: input,
paymentRequest: paymentRequest,
detection: UniversalAddressDetector.detectAddress(input),
chainBinding: _bindingFor(paymentRequest),
);
}
static AnyPayRequest fromPaymentRequest(PaymentRequest request) {
final raw = _reconstructRawInput(request);
return AnyPayRequest(
rawInput: raw,
paymentRequest: request,
detection: UniversalAddressDetector.detectAddress(raw),
chainBinding: _bindingFor(request),
);
}
static String _reconstructRawInput(PaymentRequest request) {
final scheme = request.scheme.toLowerCase();
if (scheme.isEmpty || scheme == "lightning") {
return request.address;
}
if (scheme == "ethereum") {
return ERC681URI(
address: request.address,
amount: request.amount,
contractAddress: request.contractAddress,
chainId: request.chainId ?? 1,
rawTokenAmount: request.rawTokenAmount,
).toString();
}
final amount = request.amount.isNotEmpty ? "?amount=${request.amount}" : "";
return "${request.scheme}:${request.address}$amount";
}
static ChainBinding _bindingFor(PaymentRequest request) {
final scheme = request.scheme.toLowerCase();
if (scheme == "ethereum") {
final chainId = request.chainId;
return chainId != null ? ExplicitEvmChain(chainId) : const ChainlessEvm();
}
if (scheme.isNotEmpty) {
try {
final chainId = getChainIdByCryptoCurrency(CryptoCurrency.fromString(scheme));
if (chainId != null) {
return ExplicitEvmChain(chainId);
}
} catch (_) {}
}
return const NoEvmBinding();
}
}
+109
View File
@@ -0,0 +1,109 @@
import "package:hash_wallet/core/anypay/anypay_models.dart";
import "package:hash_wallet/core/anypay/anypay_router.dart";
import "package:hash_wallet/reactions/wallet_connect.dart";
import "package:hash_wallet/utils/token_utilities.dart";
import "package:cw_core/crypto_currency.dart";
import "package:cw_core/wallet_type.dart";
class AnyPayResolver {
AnyPayResolver({AnyPayTokenLookup? tokenLookup})
: _tokenLookup = tokenLookup ?? const TokenUtilitiesLookup();
final AnyPayTokenLookup _tokenLookup;
Future<AnyPayResolution> resolve(AnyPayRequest request, WalletSnapshot snapshot) async {
if (!request.hasContract) {
return const NoTokenRequested();
}
final contract = request.contractAddress!;
final detectedType = request.detection.detectedWalletType;
if (detectedType != null && isEVMCompatibleChain(detectedType)) {
return _resolveEvm(request, snapshot, contract);
}
final token = await _tokenLookup.findTokenByAddress(
walletType: detectedType ?? snapshot.type,
address: contract,
);
if (token == null) {
return TokenUnknown(contract);
}
return TokenResolved(
token: token,
amountOverride: request.paymentRequest.resolveTokenAmount(token),
);
}
Future<AnyPayResolution> _resolveEvm(
AnyPayRequest request,
WalletSnapshot snapshot,
String contract,
) async {
final requestedChainId = AnyPayRouter.requestedEvmChainId(request, snapshot) ?? 1;
final lookupType = snapshot.supportedEvmChains[requestedChainId];
if (lookupType != null) {
final token =
await _tokenLookup.findTokenByAddress(walletType: lookupType, address: contract);
if (token != null) {
return TokenResolved(
token: token,
chainId: requestedChainId,
amountOverride: request.paymentRequest.resolveTokenAmount(token),
);
}
}
// The QRs from our old app versions omit the chainId on mainnet, so a contract the target
// network does not know may still belong to another EVM network.
if (request.chainBinding is ChainlessEvm) {
final reboundChainId = await _tokenLookup.findEvmChainIdForContract(
contract,
excludingChainId: requestedChainId,
);
final reboundType =
reboundChainId != null ? snapshot.supportedEvmChains[reboundChainId] : null;
if (reboundType != null) {
final token =
await _tokenLookup.findTokenByAddress(walletType: reboundType, address: contract);
if (token != null) {
return TokenResolved(
token: token,
chainId: reboundChainId,
amountOverride: request.paymentRequest.resolveTokenAmount(token),
);
}
}
}
return TokenUnknown(contract);
}
}
abstract class AnyPayTokenLookup {
Future<CryptoCurrency?> findTokenByAddress({
required WalletType walletType,
required String address,
});
Future<int?> findEvmChainIdForContract(String contractAddress, {int? excludingChainId});
}
class TokenUtilitiesLookup implements AnyPayTokenLookup {
const TokenUtilitiesLookup();
@override
Future<CryptoCurrency?> findTokenByAddress({
required WalletType walletType,
required String address,
}) =>
TokenUtilities.findTokenByAddress(walletType: walletType, address: address);
@override
Future<int?> findEvmChainIdForContract(String contractAddress, {int? excludingChainId}) =>
TokenUtilities.findEvmChainIdForContract(contractAddress, excludingChainId: excludingChainId);
}
+135
View File
@@ -0,0 +1,135 @@
import "package:hash_wallet/core/address_validator.dart";
import "package:hash_wallet/core/anypay/anypay_models.dart";
import "package:hash_wallet/reactions/wallet_connect.dart";
import "package:cw_core/crypto_currency.dart";
import "package:cw_core/wallet_type.dart";
class AnyPayRouter {
static final List<RegExp> _swapIncompatibleAddressPatterns = [
RegExp("^(?:${AddressValidator.silentPaymentAddressPatternMainnet})\$"),
RegExp("^(?:${AddressValidator.silentPaymentAddressPatternTestnet})\$"),
RegExp("^(?:${AddressValidator.mWebAddressPattern})\$"),
];
static bool _isSwapIncompatibleAddress(String address) =>
_swapIncompatibleAddressPatterns.any((pattern) => pattern.hasMatch(address));
static AnyPayDecision route(
AnyPayRequest request,
WalletSnapshot snapshot,
AnyPayResolution resolution,
) {
if (request.rawInput.trim().isEmpty) {
return const AnyPayEmptyInput();
}
final detection = request.detection;
final detectedType = detection.detectedWalletType;
if (!detection.isValid || detectedType == null) {
return const AnyPayApplyToCurrentWallet();
}
if (isEVMCompatibleChain(detectedType)) {
if (!snapshot.hasEvmProxy) {
return const AnyPayApplyToCurrentWallet();
}
return _routeEvm(request, snapshot, resolution);
}
if (resolution is TokenUnknown) {
return AnyPayUnsupportedToken(resolution.contract);
}
if (detectedType == snapshot.type) {
return AnyPayApplyToCurrentWallet(
token: resolution is TokenResolved ? resolution.token : null,
amountOverride: resolution is TokenResolved ? resolution.amountOverride : null,
fallbackCurrency: _nativeRequestCurrency(request),
);
}
return AnyPayCrossChainPayment(
targetWalletType: detectedType,
wallets: snapshot.walletsOfType(detectedType),
token: resolution is TokenResolved ? resolution.token : null,
amountOverride: resolution is TokenResolved ? resolution.amountOverride : null,
canSwap: !_isSwapIncompatibleAddress(request.address),
);
}
static AnyPayDecision _routeEvm(
AnyPayRequest request,
WalletSnapshot snapshot,
AnyPayResolution resolution,
) {
final requestedChainId = requestedEvmChainId(request, snapshot);
if (requestedChainId == null) {
if (snapshot.currentWalletIsEvm) {
return const AnyPayApplyToCurrentWallet();
}
return const AnyPayEvmNetworkChoice();
}
if (!snapshot.supportedEvmChains.containsKey(requestedChainId)) {
return AnyPayUnsupportedNetwork(requestedChainId);
}
if (resolution is TokenUnknown) {
return AnyPayUnsupportedToken(resolution.contract);
}
int targetChainId = requestedChainId;
if (resolution is TokenResolved && resolution.chainId != null) {
targetChainId = resolution.chainId!;
}
final token = resolution is TokenResolved ? resolution.token : null;
final amountOverride = resolution is TokenResolved ? resolution.amountOverride : null;
if (snapshot.currentWalletIsEvm && snapshot.currentChainId == targetChainId) {
return AnyPayApplyToCurrentWallet(
token: token,
amountOverride: amountOverride,
fallbackCurrency: _nativeRequestCurrency(request),
);
}
final targetWalletType = snapshot.supportedEvmChains[targetChainId]!;
return AnyPayCrossChainPayment(
targetWalletType: targetWalletType,
targetChainId: targetChainId,
wallets: snapshot.walletsOfType(targetWalletType),
token: token,
amountOverride: amountOverride,
);
}
static CryptoCurrency? _nativeRequestCurrency(AnyPayRequest request) {
if (request.paymentRequest.scheme.isEmpty || request.hasContract || request.amount.isEmpty) {
return null;
}
return request.detection.detectedCurrency;
}
static int? requestedEvmChainId(AnyPayRequest request, WalletSnapshot snapshot) {
final binding = request.chainBinding;
if (binding is ExplicitEvmChain) {
return binding.chainId;
}
if (binding is ChainlessEvm) {
// the eip 681 spec says we should use the current chain id if there is no chainId in the incoming request
return snapshot.currentWalletIsEvm ? snapshot.currentChainId ?? 1 : 1;
}
return null;
}
}
+138
View File
@@ -0,0 +1,138 @@
import "package:hash_wallet/core/anypay/anypay_models.dart";
import "package:hash_wallet/core/anypay/anypay_parser.dart";
import "package:hash_wallet/core/anypay/anypay_resolver.dart";
import "package:hash_wallet/core/anypay/anypay_router.dart";
import "package:hash_wallet/evm/evm.dart";
import "package:hash_wallet/new-ui/services/wallet_switch_service.dart";
import "package:hash_wallet/reactions/wallet_connect.dart";
import "package:hash_wallet/store/app_store.dart";
import "package:hash_wallet/utils/payment_request.dart";
import "package:cw_core/amount/money.dart";
import "package:cw_core/crypto_currency.dart";
import "package:cw_core/currency_for_wallet_type.dart";
import "package:cw_core/utils/print_verbose.dart";
import "package:cw_core/wallet_info.dart";
import "package:cw_core/wallet_type.dart";
class AnyPayService {
AnyPayService({
required this.appStore,
required this.walletSwitchService,
AnyPayResolver? resolver,
}) : resolver = resolver ?? AnyPayResolver();
final AppStore appStore;
final WalletSwitchService walletSwitchService;
final AnyPayResolver resolver;
Future<AnyPayEvaluation> evaluateRawInput(String input) => _evaluate(AnyPayParser.fromRaw(input));
Future<AnyPayEvaluation> evaluatePaymentRequest(PaymentRequest request) =>
_evaluate(AnyPayParser.fromPaymentRequest(request));
Future<AnyPayEvaluation> evaluateForEvmChain(AnyPayRequest request, int chainId) => _evaluate(
AnyPayRequest(
rawInput: request.rawInput,
paymentRequest: request.paymentRequest,
detection: request.detection,
chainBinding: ExplicitEvmChain(chainId),
),
);
Future<AnyPayEvaluation> _evaluate(AnyPayRequest request) async {
try {
final snapshot = await _buildWalletSnapshot();
final resolution = await resolver.resolve(request, snapshot);
return AnyPayEvaluation(
request: request,
decision: AnyPayRouter.route(request, snapshot, resolution),
);
} catch (e) {
printV("anypay evaluation failed: $e");
return AnyPayEvaluation(
request: request,
decision: const AnyPayApplyToCurrentWallet(),
);
}
}
Future<WalletSnapshot> _buildWalletSnapshot() async {
final wallet = appStore.wallet!;
final currentWalletIsEvm = isEVMCompatibleChain(wallet.type);
final supportedEvmChains = <int, WalletType>{};
if (evm != null) {
for (final chain in evm!.getAllChains()) {
final walletType = evm!.getWalletTypeByChainId(chain.chainId);
if (walletType != null) {
supportedEvmChains[chain.chainId] = walletType;
}
}
}
return WalletSnapshot(
type: wallet.type,
currentWalletIsEvm: currentWalletIsEvm,
currentChainId: currentWalletIsEvm && evm != null
? evm!.getSelectedChainId(wallet) ?? evm!.getChainIdByWalletType(wallet.type)
: null,
wallets: await WalletInfo.getAll(),
supportedEvmChains: supportedEvmChains,
hasEvmProxy: evm != null,
hasSolanaProxy: false,
hasTronProxy: false,
);
}
AnyPaySwapIntent buildSwapIntent(
AnyPayRequest request, {
required WalletType targetWalletType,
int? targetChainId,
CryptoCurrency? token,
}) {
final receiveCurrency = token ??
(targetChainId != null
? walletTypeToCryptoCurrency(targetWalletType, chainId: targetChainId)
: request.detection.detectedCurrency ?? walletTypeToCryptoCurrency(targetWalletType));
final amountValue = token != null
? request.paymentRequest.resolveTokenAmount(token)
: (request.amount.isNotEmpty ? request.amount : null);
return AnyPaySwapIntent(
request: request,
receiveCurrency: receiveCurrency,
targetWalletType: targetWalletType,
receiveAmount: amountValue != null ? Money.tryParse(amountValue, receiveCurrency) : null,
);
}
Future<bool> switchWalletForPayment(WalletInfo walletInfo, {int? chainId}) async {
try {
await walletSwitchService.switchToWallet(walletInfo);
} catch (e) {
printV("wallet switch failed: $e");
return false;
}
final wallet = appStore.wallet!;
if (chainId != null && evm != null && isEVMCompatibleChain(wallet.type)) {
try {
final node = appStore.settingsStore.getCurrentNode(wallet.type, chainId: chainId);
await evm!.selectChain(wallet, chainId, node: node);
} catch (e, s) {
printV("switchWalletForPayment chain select failed: $e\n$s");
return false;
}
}
try {
await wallet.updateBalance();
} catch (e) {
printV("balance refresh after wallet switch failed: $e");
}
return true;
}
}
+1
View File
@@ -36,6 +36,7 @@ class AuthService with Store {
static const List<String> _alwaysAuthenticateRoutes = [
Routes.showKeys,
Routes.seed,
Routes.backup,
Routes.setupPin,
Routes.setup_2faPage,
+6 -2
View File
@@ -208,10 +208,14 @@ class BackgroundSync {
}
await sharedPreferences.setString(PreferencesKey.backgroundSyncLastTrigger(wallet.name),
tx.date.add(Duration(minutes: 1)).toIso8601String());
final action = tx.direction == TransactionDirection.incoming ? "Received" : "Sent";
final action = tx.additionalInfo['isAutoShield'] == true
? "Autoshield"
: tx.direction == TransactionDirection.incoming
? "Received"
: "Sent";
if (sharedPreferences.getBool(PreferencesKey.backgroundSyncNotificationsEnabled) ?? false) {
await showNotification(
"$action ${wallet.currency.fullName} in ${wallet.name}", "${tx.amountFormatted()}");
"$action ${wallet.currency.fullName} in ${wallet.name}", "${tx.amount.toString()}");
}
printV(
"${wallet.currency.fullName} in ${wallet.name}: TX: ${tx.date} ${tx.amount} ${tx.direction}");
+7
View File
@@ -18,6 +18,9 @@ import 'package:hash_wallet/core/key_service.dart';
import 'package:hash_wallet/entities/encrypt.dart';
import 'package:hash_wallet/entities/preferences_key.dart';
import 'package:hash_wallet/entities/secret_store_key.dart';
import 'package:cw_core/erc20_token_legacy.dart' show performErc20TokenHiveMigration;
import 'package:cw_core/spl_token_legacy.dart' show performSplTokenHiveMigration;
import 'package:cw_core/tron_token_legacy.dart' show performTronTokenHiveMigration;
import 'package:cw_core/wallet_info.dart';
import 'package:hash_wallet/exchange/trade_legacy.dart';
import 'package:hash_wallet/.secrets.g.dart' as secrets;
@@ -113,6 +116,10 @@ class $BackupService {
Future<void> verifyWallets() async {
await performHiveMigration(); // for backups made before sqlite migration
await performTradeHiveMigration(_secureStorage);
await performErc20TokenHiveMigration();
await performSplTokenHiveMigration();
await performTronTokenHiveMigration();
correctWallets = (await WalletInfo.getAll())
.where((info) => availableWalletTypes.contains(info.type))
.toList();
+49 -3
View File
@@ -147,6 +147,21 @@ class BackupMetadata {
}
}
class IncompatibleBackupAppException implements Exception {
IncompatibleBackupAppException({
required this.sourceAppName,
required this.currentAppName,
});
final String sourceAppName;
final String currentAppName;
@override
String toString() {
return 'This backup was created in $sourceAppName and cannot be restored in $currentAppName.';
}
}
class BackupServiceV3 extends $BackupService {
BackupServiceV3(super.secureStorage, super.transactionDescriptionBox, super.keyService,
super.sharedPreferences);
@@ -201,7 +216,8 @@ class BackupServiceV3 extends $BackupService {
}
}
Future<void> importBackupFile(File file, String password, {String nonce = secrets.backupSalt}) {
Future<void> importBackupFile(File file, String password,
{String nonce = secrets.backupSalt, bool checkBackupApp = true}) {
final version = getVersionFile(file);
switch (version) {
case BackupVersion.unknown:
@@ -214,12 +230,12 @@ class BackupServiceV3 extends $BackupService {
case BackupVersion.v2:
return super.importBackupV2(file.readAsBytesSync(), password);
case BackupVersion.v3:
return importBackupFileV3(file, password, nonce: nonce);
return importBackupFileV3(file, password, nonce: nonce, checkBackupApp: checkBackupApp);
}
}
Future<void> importBackupFileV3(File file, String password,
{String nonce = secrets.backupSalt}) async {
{String nonce = secrets.backupSalt, bool checkBackupApp = true}) async {
// Overall design of v3 backup is the following:
// 1. backup.zip - plaintext zip file that user can open with any archive manager
// 2. backup.zip/README.txt - text file to let user know what is inside of this file
@@ -228,6 +244,11 @@ class BackupServiceV3 extends $BackupService {
final inputStream = InputFileStream(file.path);
final archive = ZipDecoder().decodeStream(inputStream);
if (checkBackupApp) {
await _throwIfBackupWasCreatedInAnotherApp(archive);
}
final metadataFile = archive.findFile('metadata.json');
if (metadataFile == null) {
throw Exception('Invalid v3 backup: missing metadata.json');
@@ -497,6 +518,31 @@ This backup was created on ${DateTime.now().toIso8601String()}
return file;
}
Future<void> _throwIfBackupWasCreatedInAnotherApp(Archive archive) async {
final readmeFile = archive.findFile('README.txt');
if (readmeFile == null) return;
final readmeBytes = readmeFile.rawContent?.readBytes();
if (readmeBytes == null) return;
final readmeString = utf8.decode(readmeBytes, allowMalformed: true);
final sourceAppName = _extractBackupAppName(readmeString);
if (sourceAppName == null) return;
final currentAppName = (await PackageInfo.fromPlatform()).appName;
if (sourceAppName == currentAppName) return;
throw IncompatibleBackupAppException(
sourceAppName: sourceAppName,
currentAppName: currentAppName,
);
}
String? _extractBackupAppName(String readme) {
final match = RegExp(r'^This is a (.+) backup\.', multiLine: true).firstMatch(readme);
return match?.group(1)?.trim();
}
static const chunkSize = 24 * 1024 * 1024; // 24MiB
File setVersionFile(File file, BackupVersion version) {
+339
View File
@@ -0,0 +1,339 @@
import 'dart:io';
import 'package:hash_wallet/entities/transaction_description.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/src/screens/backup/backup_page.dart';
import 'package:hash_wallet/src/widgets/alert_with_two_actions.dart';
import 'package:hash_wallet/utils/share_util.dart';
import 'package:hash_wallet/utils/show_bar.dart';
import 'package:hash_wallet/utils/show_pop_up.dart';
import 'package:hash_wallet/view_model/dashboard/action_list_item.dart';
import 'package:hash_wallet/view_model/dashboard/anonpay_transaction_list_item.dart';
import 'package:hash_wallet/view_model/dashboard/date_section_item.dart';
import 'package:hash_wallet/view_model/dashboard/order_list_item.dart';
import 'package:hash_wallet/view_model/dashboard/payjoin_transaction_list_item.dart';
import 'package:hash_wallet/view_model/dashboard/trade_list_item.dart';
import 'package:hash_wallet/view_model/dashboard/transaction_list_item.dart';
import 'package:collection/collection.dart';
import 'package:cw_core/transaction_direction.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:intl/intl.dart';
import 'package:path_provider/path_provider.dart';
class CsvExportService {
CsvExportService({required this.transactionDescriptionBox});
final Box<TransactionDescription> transactionDescriptionBox;
static const _columns = [
'record_type',
'date_time',
'type',
'amount',
'currency',
'fee',
'tx_id',
'address',
'status',
'note',
'from_amount',
'from_currency',
'to_amount',
'to_currency',
'trade_id',
'provider',
'confirmations',
];
static const _utf8Bom = '';
static const _formulaTriggers = {'=', '+', '-', '@', '\t', '\r'};
static final _plainNumber = RegExp(r'^-?\d+(\.\d+)?$');
String buildCsvContent(List<ActionListItem> items) {
final buf = StringBuffer();
buf.write(_utf8Bom);
buf.writeln(_columns.join(','));
final primaryAddress = _primaryAddressOf(items);
for (final item in items) {
if (item is DateSectionItem) continue;
final row = _buildRow(item, primaryAddress);
if (row != null) buf.writeln(row);
}
return buf.toString();
}
String _primaryAddressOf(List<ActionListItem> items) {
for (final item in items) {
if (item is TransactionListItem) {
return item.balanceViewModel.wallet.walletAddresses.primaryAddress;
}
}
return '';
}
String _noteFor(TransactionListItem item, String primaryAddress) {
final txHash = item.transaction.txHash;
final descriptionKey = '${txHash}_$primaryAddress';
final description = transactionDescriptionBox.values
.firstWhereOrNull((val) => val.id == descriptionKey || val.id == txHash);
return description?.note ?? '';
}
String? _buildRow(ActionListItem item, String primaryAddress) {
if (item is TransactionListItem) return _transactionRow(item, primaryAddress);
if (item is TradeListItem) return _tradeRow(item);
if (item is OrderListItem) return _orderRow(item);
if (item is AnonpayTransactionListItem) return _anonpayRow(item);
if (item is PayjoinTransactionListItem) return _payjoinRow(item);
return null;
}
String _transactionRow(TransactionListItem item, String primaryAddress) {
final tx = item.transaction;
final type = tx.direction == TransactionDirection.incoming ? 'incoming' : 'outgoing';
final status = tx.isPending ? 'pending' : 'confirmed';
// Prefer tx.to/tx.from; fall back to address lists for chains that don't populate them.
final address = tx.direction == TransactionDirection.incoming
? (tx.from?.isNotEmpty == true ? tx.from! : (tx.inputAddresses?.firstOrNull ?? ''))
: (tx.to?.isNotEmpty == true ? tx.to! : (tx.outputAddresses?.firstOrNull ?? ''));
final fee = tx.fee != null && !tx.fee!.isZero ? tx.fee.toString() : '';
return _row([
'transaction',
_isoDate(tx.date),
type,
tx.amount.toString(),
tx.amount.currency.symbol,
fee,
tx.id,
address,
status,
_noteFor(item, primaryAddress),
'',
'',
'',
'',
'',
'',
tx.confirmations.toString(),
]);
}
String _tradeRow(TradeListItem item) {
final trade = item.trade;
return _row([
'trade',
_isoDate(trade.createdAt ?? DateTime.fromMillisecondsSinceEpoch(0, isUtc: true)),
'swap',
'',
'',
trade.fee?.toString() ?? '',
trade.outputTransaction ?? '',
trade.payoutAddress ?? '',
trade.state.title,
trade.memo ?? '',
trade.amount,
trade.from?.name ?? '',
trade.receiveAmount ?? '',
trade.to?.name ?? '',
trade.id,
trade.provider.title,
'',
]);
}
String _orderRow(OrderListItem item) {
final order = item.order;
return _row([
'order',
_isoDate(order.createdAt),
order.source.title,
order.amountFormatted(),
order.from ?? '',
'',
order.transferId,
'',
order.state.title,
'',
order.amountFormatted(),
order.from ?? '',
order.receiveAmount ?? '',
order.to ?? '',
order.id,
order.providerTitle,
'',
]);
}
String _anonpayRow(AnonpayTransactionListItem item) {
final tx = item.transaction;
final amount = tx.fiatAmount?.toString() ?? tx.amountTo?.toString() ?? '';
final currency = tx.fiatEquiv ?? tx.coinTo;
return _row([
'anonpay',
_isoDate(tx.createdAt),
'anonymous_payment',
amount,
currency,
'',
tx.invoiceId,
tx.address,
tx.status,
'',
'',
'',
'',
'',
'',
tx.provider,
'',
]);
}
String _payjoinRow(PayjoinTransactionListItem item) {
final session = item.session;
final type = session.isSenderSession ? 'send' : 'receive';
final amount = session.rawAmount ?? '0';
return _row([
'payjoin',
_isoDate(session.inProgressSince ?? DateTime.fromMillisecondsSinceEpoch(0, isUtc: true)),
type,
amount,
'BTC',
'',
session.txId ?? '',
'',
item.status,
'',
'',
'',
'',
'',
'',
'',
'',
]);
}
String _row(List<String> fields) => fields.map(escapeField).join(',');
String escapeField(String field) {
final value = _neutralizeFormula(field);
if (value.contains(',') ||
value.contains('"') ||
value.contains('\n') ||
value.contains('\r')) {
return '"${value.replaceAll('"', '""')}"';
}
return value;
}
String _neutralizeFormula(String field) {
if (field.isEmpty || !_formulaTriggers.contains(field[0])) return field;
if (_plainNumber.hasMatch(field)) return field;
return "'$field";
}
String _isoDate(DateTime dt) => dt.toUtc().toIso8601String();
Future<void> exportToCsv(List<ActionListItem> items, BuildContext context) async {
final dataItems =
items.whereType<ActionListItem>().where((e) => e is! DateSectionItem).toList();
if (dataItems.isEmpty) {
await showBar<void>(context, S.current.csv_nothing_to_export);
return;
}
final now = DateTime.now();
final fileName = 'cake_wallet_export_${DateFormat('yyyyMMdd_HHmmss').format(now)}.csv';
late File csvFile;
final exportFuture = Future(() async {
final content = buildCsvContent(items);
csvFile = await _writeTempFile(fileName, content);
});
showPersistentActionOverlay(context, exportFuture, text: S.current.generating_csv);
await exportFuture;
if (!context.mounted) return;
if (Platform.isAndroid) {
_showAndroidExportDialog(context, csvFile, fileName);
} else if (Platform.isIOS) {
await _shareFile(csvFile, fileName, context);
} else {
await _saveFileDesktop(csvFile, fileName);
}
}
void _showAndroidExportDialog(BuildContext context, File csvFile, String fileName) {
showPopUp<void>(
context: context,
builder: (dialogContext) {
return AlertWithTwoActions(
alertTitle: S.current.export_csv,
alertContent: S.current.select_destination,
rightButtonText: S.current.save_to_downloads,
leftButtonText: S.current.share,
actionRightButton: () async {
await _saveToDownloads(fileName, csvFile);
Navigator.of(dialogContext).pop();
await showBar<void>(context, S.current.file_saved);
await csvFile.delete();
},
actionLeftButton: () async {
Navigator.of(dialogContext).pop();
await _shareFile(csvFile, fileName, context);
},
);
},
);
}
Future<void> _shareFile(File file, String fileName, BuildContext context) async {
await ShareUtil.shareFile(filePath: file.path, fileName: fileName, context: context);
if (await file.exists()) await file.delete();
}
Future<void> _saveFileDesktop(File csvFile, String fileName) async {
final outputPath = await FilePicker.platform.saveFile(
dialogTitle: 'Save CSV export',
fileName: fileName,
lockParentWindow: true,
);
if (outputPath == null) return;
await csvFile.copy(outputPath);
await csvFile.delete();
}
Future<File> _writeTempFile(String fileName, String content) async {
final dir = await getApplicationDocumentsDirectory();
final file = File('${dir.path}/$fileName');
if (file.existsSync()) file.deleteSync();
await file.writeAsString(content, flush: true);
return file;
}
Future<void> _saveToDownloads(String fileName, File file) async {
if (!Platform.isAndroid) return;
const downloadsPath = '/storage/emulated/0/Download';
final dest = File('$downloadsPath/$fileName');
if (dest.existsSync()) dest.deleteSync();
await file.copy(dest.path);
}
}
+21 -15
View File
@@ -1,6 +1,6 @@
import 'dart:convert';
import "dart:convert";
import 'package:cw_core/utils/proxy_wrapper.dart';
import "package:cw_core/utils/proxy_wrapper.dart";
Future<String?> getBolt11FromLightingAddress(String lightningAddress, {int amount = 0}) async {
try {
@@ -31,28 +31,34 @@ Uri getURlOfLightningAddress(String lightningAddress) {
}
class _LNURLPResponseDTO {
const _LNURLPResponseDTO(
this.callback,
this.maxSendable,
this.minSendable,
this.tag,
this.metadata,
);
factory _LNURLPResponseDTO.fromJson(Map<String, dynamic> map) => _LNURLPResponseDTO(
map["callback"] as String,
map["maxSendable"] as int,
map["minSendable"] as int,
map["tag"] as String,
map["metadata"] as String,
);
final String callback;
final int maxSendable;
final int minSendable;
final String tag;
final String metadata;
const _LNURLPResponseDTO(
this.callback, this.maxSendable, this.minSendable, this.tag, this.metadata);
static _LNURLPResponseDTO fromJson(Map<String, dynamic> map) => _LNURLPResponseDTO(
map["callback"] as String,
map["maxSendable"] as int,
map["minSendable"] as int,
map["tag"] as String,
map["metadata"] as String);
}
class _LNURLPCallbackResponseDTO {
final String pr;
const _LNURLPCallbackResponseDTO(this.pr);
static _LNURLPCallbackResponseDTO fromJson(Map<String, dynamic> map) =>
factory _LNURLPCallbackResponseDTO.fromJson(Map<String, dynamic> map) =>
_LNURLPCallbackResponseDTO(map["pr"] as String);
final String pr;
}
+7 -9
View File
@@ -14,7 +14,6 @@ class NodeSwitchingService {
NodeSwitchingService({
required this.appStore,
required this.settingsStore,
required this.nodeSource,
});
static const int _healthCheckIntervalSeconds = 30;
@@ -38,7 +37,6 @@ class NodeSwitchingService {
final AppStore appStore;
final SettingsStore settingsStore;
final Box<Node> nodeSource;
final Map<WalletType, List<dynamic>> _usedNodeKeys = {};
@@ -118,13 +116,13 @@ class NodeSwitchingService {
WalletType walletType,
) async {
for (final node in nodes) {
if (!_usedNodeKeys[walletType]!.contains(node.key)) {
if (!_usedNodeKeys[walletType]!.contains(node.id)) {
final isActive = await node.requestNode();
if (isActive) {
return node;
} else {
printV('Node ${node.uriRaw} is not active. Marking as used.');
_usedNodeKeys[walletType]!.add(node.key);
_usedNodeKeys[walletType]!.add(node.id);
}
}
}
@@ -157,7 +155,7 @@ class NodeSwitchingService {
final currentNode = settingsStore.getCurrentNode(nodeWalletType, chainId: chainId);
// Get all trusted nodes for this wallet type
final trustedNodes = nodeSource.values
final trustedNodes = (await Node.getAll())
.where((node) => node.type == nodeWalletType && node.isEnabledForAutoSwitching)
.toList();
@@ -171,8 +169,8 @@ class NodeSwitchingService {
_usedNodeKeys.putIfAbsent(nodeWalletType, () => []);
// Add current node to used list if not already there
if (!_usedNodeKeys[nodeWalletType]!.contains(currentNode.key)) {
_usedNodeKeys[nodeWalletType]!.add(currentNode.key);
if (!_usedNodeKeys[nodeWalletType]!.contains(currentNode.id)) {
_usedNodeKeys[nodeWalletType]!.add(currentNode.id);
}
// Try to find an active unused node
@@ -199,8 +197,8 @@ class NodeSwitchingService {
}
// Ensure the selected node is marked as used
if (!_usedNodeKeys[nodeWalletType]!.contains(nextNode.key)) {
_usedNodeKeys[nodeWalletType]!.add(nextNode.key);
if (!_usedNodeKeys[nodeWalletType]!.contains(nextNode.id)) {
_usedNodeKeys[nodeWalletType]!.add(nextNode.id);
}
printV(
+1 -1
View File
@@ -42,7 +42,7 @@ String syncStatusTitle(SyncStatus syncStatus, SyncStatusDisplayMode syncStatusDi
}
if (syncStatus is SyncedSyncStatus) {
return "";
return S.current.sync_synced;
}
if (syncStatus is FailedSyncStatus) {
+6
View File
@@ -238,6 +238,12 @@ class UniversalAddressDetector {
currency: CryptoCurrency.doge,
),
// Zcash (transparent, Sapling shielded, unified)
_DetectionPattern(
pattern: RegExp("^(?:${AddressValidator.zcashAddressPattern})\$"),
currency: CryptoCurrency.zec,
),
// Solana (Base58 format)
_DetectionPattern(
pattern: RegExp(r'^[1-9A-HJ-NP-Za-km-z]{43,44}$'),