Implement BIP 353 username resolution (#2017)

Serhii committed Feb 17, 2025 at 23:05 UTC 9ae533cfbd672a8972dd4f28c9a444f0e3504f60
4 files changed +179
lib/entities/bip_353_record.dart new
+151
@@ -0,0 +1,151 @@
1 +import 'package:basic_utils/basic_utils.dart';
2 +import 'package:cake_wallet/generated/i18n.dart';
3 +import 'package:cake_wallet/src/widgets/picker.dart';
4 +import 'package:cake_wallet/utils/show_pop_up.dart';
5 +import 'package:cw_core/crypto_currency.dart';
6 +import 'package:cw_core/utils/print_verbose.dart';
7 +import 'package:flutter/material.dart';
8 +
9 +class Bip353Record {
10 + Bip353Record({
11 + required this.uri,
12 + required this.domain,
13 + });
14 +
15 + final String uri;
16 + final String domain;
17 +
18 + static const Map<String, String> keyDisplayMap = {
19 + 'lno': 'BOLT 12 Offer',
20 + 'sp': 'Silent Payment',
21 + 'address': 'On-Chain Address',
22 + };
23 +
24 + static Future<Map<String, String>?> fetchUriByCryptoCurrency(
25 + String bip353Name, String asset) async {
26 + try {
27 + // 1. Parse the user and domain from "user@domain"
28 + final parts = bip353Name.split('@');
29 +
30 + if (parts.length != 2) return null;
31 +
32 + final userPart = parts[0];
33 + final domainPart = parts[1];
34 +
35 + // 2. Construct the correct subdomain: "user._bitcoin-payment.domain"
36 + final bip353Domain = '$userPart.user._bitcoin-payment.$domainPart';
37 +
38 + // 3. Lookup the TXT record with DNSSEC
39 + final txtRecords = await DnsUtils.lookupRecord(
40 + bip353Domain,
41 + RRecordType.TXT,
42 + dnssec: true,
43 + );
44 +
45 + if (txtRecords == null) return null;
46 +
47 + final assetName = CryptoCurrency.fromString(asset).fullName;
48 + if (assetName == null) throw Exception('Invalid asset name');
49 + final formattedAssetName = assetName.toLowerCase().replaceAll(' ', '') + ':';
50 +
51 + for (final record in txtRecords) {
52 + final data = record.data.replaceAll('"', '');
53 + if (data.startsWith(formattedAssetName)) {
54 + return _parseAssetUri(data, formattedAssetName);
55 + }
56 + }
57 + } catch (e) {
58 + printV('BIP353Record.fetchBitcoinUri error: $e');
59 + }
60 + return null;
61 + }
62 +
63 + static Map<String, String>? _parseAssetUri(String fullUri, String prefix) {
64 + final afterPrefix = fullUri.substring(prefix.length);
65 + if (afterPrefix.isEmpty) return null;
66 +
67 + final questionIndex = afterPrefix.indexOf('?');
68 + if (questionIndex == -1) {
69 + return {'address': afterPrefix};
70 + } else {
71 + final addressPart = afterPrefix.substring(0, questionIndex);
72 + final queryPart = afterPrefix.substring(questionIndex + 1);
73 + final result = <String, String>{};
74 +
75 + if (addressPart.isNotEmpty) result['address'] = addressPart;
76 + final queryMap = Uri.splitQueryString(queryPart);
77 + result.addAll(queryMap);
78 +
79 + return result;
80 + }
81 + }
82 +
83 + static Future<String?> pickBip353AddressChoice(
84 + BuildContext context,
85 + String bip353Name,
86 + Map<String, String> addressMap,
87 + ) async {
88 + if (addressMap.length == 1) {
89 + return addressMap.values.first;
90 + }
91 +
92 + final chosenAddress = await _showAddressChoiceDialog(context, bip353Name, addressMap);
93 +
94 + return chosenAddress;
95 + }
96 +
97 + static Future<String?> _showAddressChoiceDialog(
98 + BuildContext context,
99 + String bip353Name,
100 + Map<String, String> addressMap,
101 + ) async {
102 + final entriesList = addressMap.entries.toList();
103 + final displayItems = entriesList.map((e) {
104 + final extendedKeyName = keyDisplayMap[e.key] ?? e.key;
105 + final truncatedValue = _truncate(e.value, front: 6, back: 6);
106 + return '$extendedKeyName : $truncatedValue';
107 + }).toList();
108 +
109 + String? selectedDisplayItem;
110 +
111 + if (context.mounted) {
112 + await showPopUp<void>(
113 + context: context,
114 + builder: (dialogContext) {
115 + return Picker<String>(
116 + selectedAtIndex: 0,
117 + title:
118 + '$bip353Name \n was successfully resolved to the following addresses, please choose one:',
119 + items: displayItems,
120 + onItemSelected: (String displayItem) {
121 + selectedDisplayItem = displayItem;
122 + },
123 + );
124 + },
125 + );
126 + }
127 +
128 + if (selectedDisplayItem == null) {
129 + if (displayItems.isEmpty) {
130 + return null;
131 + }
132 + selectedDisplayItem = displayItems[0];
133 + }
134 +
135 + final index = displayItems.indexOf(selectedDisplayItem!);
136 + if (index < 0) {
137 + return null;
138 + }
139 +
140 + return entriesList[index].value;
141 + }
142 +
143 + static String _truncate(String value, {int front = 6, int back = 6}) {
144 + if (value.length <= front + back) return value;
145 +
146 + final start = value.substring(0, front);
147 + final end = value.substring(value.length - back);
148 + return '$start...$end';
149 + }
150 +
151 +}
lib/entities/parse_address_from_domain.dart
+11
@@ -19,6 +19,8 @@ import 'package:cw_core/wallet_type.dart';
19 import 'package:cake_wallet/entities/fio_address_provider.dart';
20 import 'package:flutter/cupertino.dart';
21
22 +import 'bip_353_record.dart';
23 +
24 class AddressResolver {
25 AddressResolver({required this.yatService, required this.wallet, required this.settingsStore})
26 : walletType = wallet.type;
@@ -273,6 +275,15 @@ class AddressResolver {
275 }
276 }
277
278 + final bip353AddressMap = await Bip353Record.fetchUriByCryptoCurrency(text, ticker);
279 +
280 + if (bip353AddressMap != null && bip353AddressMap.isNotEmpty) {
281 + final chosenAddress = await Bip353Record.pickBip353AddressChoice(context, text, bip353AddressMap);
282 + if (chosenAddress != null) {
283 + return ParsedAddress.fetchBip353AddressAddress(address: chosenAddress, name: text);
284 + }
285 + }
286 +
287 if (text.endsWith(".eth")) {
288 if (settingsStore.lookupsENS) {
289 final address = await EnsRecord.fetchEnsAddress(text, wallet: wallet);
lib/entities/parsed_address.dart
+12
@@ -15,6 +15,7 @@ enum ParseFrom {
15 thorChain,
16 wellKnown,
17 zanoAlias,
18 + bip353
19 }
20
21 class ParsedAddress {
@@ -55,6 +56,17 @@ class ParsedAddress {
56 );
57 }
58
59 + factory ParsedAddress.fetchBip353AddressAddress ({
60 + required String address,
61 + required String name,
62 + }) {
63 + return ParsedAddress(
64 + addresses: [address],
65 + name: name,
66 + parseFrom: ParseFrom.bip353,
67 + );
68 + }
69 +
70 factory ParsedAddress.fetchOpenAliasAddress(
71 {required OpenaliasRecord record, required String name}) {
72 if (record.address.isEmpty) {
lib/src/screens/send/widgets/extract_address_from_parsed.dart
+5
@@ -71,6 +71,11 @@ Future<String> extractAddressFromParsed(
71 content = S.of(context).extracted_address_content('${parsedAddress.name} (Zano Alias)');
72 address = parsedAddress.addresses.first;
73 break;
74 + case ParseFrom.bip353:
75 + title = S.of(context).address_detected;
76 + content = S.of(context).extracted_address_content('${parsedAddress.name} (BIP-353)');
77 + address = parsedAddress.addresses.first;
78 + break;
79 case ParseFrom.yatRecord:
80 if (parsedAddress.name.isEmpty) {
81 title = S.of(context).yat_error;