Implement BIP 353 username resolution (#2017)

This commit is contained in:
Serhii 2025-02-17 23:05:44 +02:00 committed by GitHub
parent 57d88663c9
commit 9ae533cfbd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 179 additions and 0 deletions

View file

@ -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<String, String> keyDisplayMap = {
'lno': 'BOLT 12 Offer',
'sp': 'Silent Payment',
'address': 'On-Chain Address',
};
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;
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<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;
}
}
static Future<String?> pickBip353AddressChoice(
BuildContext context,
String bip353Name,
Map<String, String> addressMap,
) async {
if (addressMap.length == 1) {
return addressMap.values.first;
}
final chosenAddress = await _showAddressChoiceDialog(context, bip353Name, addressMap);
return chosenAddress;
}
static Future<String?> _showAddressChoiceDialog(
BuildContext context,
String bip353Name,
Map<String, String> 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<void>(
context: context,
builder: (dialogContext) {
return Picker<String>(
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';
}
}

View file

@ -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);

View file

@ -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) {

View file

@ -71,6 +71,11 @@ Future<String> 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;