mirror of
https://github.com/Such-Software/hash-wallet.git
synced 2026-10-31 06:58:42 +01:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]+';
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ class AuthService with Store {
|
||||
|
||||
static const List<String> _alwaysAuthenticateRoutes = [
|
||||
Routes.showKeys,
|
||||
Routes.seed,
|
||||
Routes.backup,
|
||||
Routes.setupPin,
|
||||
Routes.setup_2faPage,
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -42,7 +42,7 @@ String syncStatusTitle(SyncStatus syncStatus, SyncStatusDisplayMode syncStatusDi
|
||||
}
|
||||
|
||||
if (syncStatus is SyncedSyncStatus) {
|
||||
return "";
|
||||
return S.current.sync_synced;
|
||||
}
|
||||
|
||||
if (syncStatus is FailedSyncStatus) {
|
||||
|
||||
@@ -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}$'),
|
||||
|
||||
Reference in New Issue
Block a user