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;