From 9ae533cfbd672a8972dd4f28c9a444f0e3504f60 Mon Sep 17 00:00:00 2001 From: Serhii Date: Mon, 17 Feb 2025 23:05:44 +0200 Subject: [PATCH] Implement BIP 353 username resolution (#2017) --- lib/entities/bip_353_record.dart | 151 ++++++++++++++++++ lib/entities/parse_address_from_domain.dart | 11 ++ lib/entities/parsed_address.dart | 12 ++ .../widgets/extract_address_from_parsed.dart | 5 + 4 files changed, 179 insertions(+) create mode 100644 lib/entities/bip_353_record.dart diff --git a/lib/entities/bip_353_record.dart b/lib/entities/bip_353_record.dart new file mode 100644 index 000000000..80c0099a8 --- /dev/null +++ b/lib/entities/bip_353_record.dart @@ -0,0 +1,151 @@ +import 'package:basic_utils/basic_utils.dart'; +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/src/widgets/picker.dart'; +import 'package:cake_wallet/utils/show_pop_up.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:flutter/material.dart'; + +class Bip353Record { + Bip353Record({ + required this.uri, + required this.domain, + }); + + final String uri; + final String domain; + + static const Map keyDisplayMap = { + 'lno': 'BOLT 12 Offer', + 'sp': 'Silent Payment', + 'address': 'On-Chain Address', + }; + + static Future?> 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; + + final userPart = parts[0]; + 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, + ); + + 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? _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 = {}; + + if (addressPart.isNotEmpty) result['address'] = addressPart; + final queryMap = Uri.splitQueryString(queryPart); + result.addAll(queryMap); + + return result; + } + } + + static Future pickBip353AddressChoice( + BuildContext context, + String bip353Name, + Map addressMap, + ) async { + if (addressMap.length == 1) { + return addressMap.values.first; + } + + final chosenAddress = await _showAddressChoiceDialog(context, bip353Name, addressMap); + + return chosenAddress; + } + + static Future _showAddressChoiceDialog( + BuildContext context, + String bip353Name, + Map addressMap, + ) async { + final entriesList = addressMap.entries.toList(); + final displayItems = entriesList.map((e) { + final extendedKeyName = keyDisplayMap[e.key] ?? e.key; + final truncatedValue = _truncate(e.value, front: 6, back: 6); + return '$extendedKeyName : $truncatedValue'; + }).toList(); + + String? selectedDisplayItem; + + if (context.mounted) { + await showPopUp( + context: context, + builder: (dialogContext) { + return Picker( + selectedAtIndex: 0, + title: + '$bip353Name \n was successfully resolved to the following addresses, please choose one:', + items: displayItems, + onItemSelected: (String displayItem) { + selectedDisplayItem = displayItem; + }, + ); + }, + ); + } + + if (selectedDisplayItem == null) { + if (displayItems.isEmpty) { + return null; + } + selectedDisplayItem = displayItems[0]; + } + + final index = displayItems.indexOf(selectedDisplayItem!); + if (index < 0) { + return null; + } + + return entriesList[index].value; + } + + static String _truncate(String value, {int front = 6, int back = 6}) { + if (value.length <= front + back) return value; + + final start = value.substring(0, front); + final end = value.substring(value.length - back); + return '$start...$end'; + } + +} diff --git a/lib/entities/parse_address_from_domain.dart b/lib/entities/parse_address_from_domain.dart index 28b9609f6..f5f96a8cd 100644 --- a/lib/entities/parse_address_from_domain.dart +++ b/lib/entities/parse_address_from_domain.dart @@ -19,6 +19,8 @@ import 'package:cw_core/wallet_type.dart'; import 'package:cake_wallet/entities/fio_address_provider.dart'; import 'package:flutter/cupertino.dart'; +import 'bip_353_record.dart'; + class AddressResolver { AddressResolver({required this.yatService, required this.wallet, required this.settingsStore}) : walletType = wallet.type; @@ -273,6 +275,15 @@ class AddressResolver { } } + final bip353AddressMap = await Bip353Record.fetchUriByCryptoCurrency(text, ticker); + + if (bip353AddressMap != null && bip353AddressMap.isNotEmpty) { + final chosenAddress = await Bip353Record.pickBip353AddressChoice(context, text, bip353AddressMap); + if (chosenAddress != null) { + return ParsedAddress.fetchBip353AddressAddress(address: chosenAddress, name: text); + } + } + if (text.endsWith(".eth")) { if (settingsStore.lookupsENS) { final address = await EnsRecord.fetchEnsAddress(text, wallet: wallet); diff --git a/lib/entities/parsed_address.dart b/lib/entities/parsed_address.dart index 4e95cf40e..1f2529221 100644 --- a/lib/entities/parsed_address.dart +++ b/lib/entities/parsed_address.dart @@ -15,6 +15,7 @@ enum ParseFrom { thorChain, wellKnown, zanoAlias, + bip353 } class ParsedAddress { @@ -55,6 +56,17 @@ class ParsedAddress { ); } + factory ParsedAddress.fetchBip353AddressAddress ({ + required String address, + required String name, + }) { + return ParsedAddress( + addresses: [address], + name: name, + parseFrom: ParseFrom.bip353, + ); + } + factory ParsedAddress.fetchOpenAliasAddress( {required OpenaliasRecord record, required String name}) { if (record.address.isEmpty) { diff --git a/lib/src/screens/send/widgets/extract_address_from_parsed.dart b/lib/src/screens/send/widgets/extract_address_from_parsed.dart index b416a65a2..29c4eb793 100644 --- a/lib/src/screens/send/widgets/extract_address_from_parsed.dart +++ b/lib/src/screens/send/widgets/extract_address_from_parsed.dart @@ -71,6 +71,11 @@ Future extractAddressFromParsed( content = S.of(context).extracted_address_content('${parsedAddress.name} (Zano Alias)'); address = parsedAddress.addresses.first; break; + case ParseFrom.bip353: + title = S.of(context).address_detected; + content = S.of(context).extracted_address_content('${parsedAddress.name} (BIP-353)'); + address = parsedAddress.addresses.first; + break; case ParseFrom.yatRecord: if (parsedAddress.name.isEmpty) { title = S.of(context).yat_error;