Skip to content

Commit

Permalink
update uri parsing code to support monero URIs
Browse files Browse the repository at this point in the history
bitcoin URIs now also validate properly
  • Loading branch information
sneurlax committed Aug 27, 2024
1 parent dd11214 commit b73628d
Show file tree
Hide file tree
Showing 8 changed files with 178 additions and 32 deletions.
2 changes: 1 addition & 1 deletion lib/pages/receive_view/addresses/address_card.dart
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ class _AddressCardState extends ConsumerState<AddressCard> {
key: _qrKey,
child: QR(
data: AddressUtils.buildUriString(
widget.coin,
widget.coin.uriScheme,
address.value,
{},
),
Expand Down
4 changes: 2 additions & 2 deletions lib/pages/receive_view/addresses/address_details_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ class _AddressDetailsViewState extends ConsumerState<AddressDetailsView> {
key: _qrKey,
child: QR(
data: AddressUtils.buildUriString(
ref.watch(pWalletCoin(widget.walletId)),
ref.watch(pWalletCoin(widget.walletId)).uriScheme,
address.value,
{},
),
Expand Down Expand Up @@ -289,7 +289,7 @@ class _AddressDetailsViewState extends ConsumerState<AddressDetailsView> {
key: _qrKey,
child: QR(
data: AddressUtils.buildUriString(
coin,
coin.uriScheme,
address.value,
{},
),
Expand Down
2 changes: 1 addition & 1 deletion lib/pages/receive_view/addresses/address_qr_popup.dart
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ class _AddressQrPopupState extends State<AddressQrPopup> {
key: _qrKey,
child: QR(
data: AddressUtils.buildUriString(
widget.coin,
widget.coin.uriScheme,
widget.addressString,
{},
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ class _GenerateUriQrCodeViewState extends State<GenerateUriQrCodeView> {
}

final uriString = AddressUtils.buildUriString(
widget.coin,
widget.coin.uriScheme,
receivingAddress,
queryParams,
);
Expand Down Expand Up @@ -263,7 +263,7 @@ class _GenerateUriQrCodeViewState extends State<GenerateUriQrCodeView> {
}

_uriString = AddressUtils.buildUriString(
widget.coin,
widget.coin.uriScheme,
receivingAddress,
{},
);
Expand Down
2 changes: 1 addition & 1 deletion lib/pages/receive_view/receive_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -577,7 +577,7 @@ class _ReceiveViewState extends ConsumerState<ReceiveView> {
children: [
QR(
data: AddressUtils.buildUriString(
coin,
coin.uriScheme,
address,
{},
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -448,7 +448,7 @@ class _DesktopReceiveState extends ConsumerState<DesktopReceive> {
Center(
child: QR(
data: AddressUtils.buildUriString(
coin,
coin.uriScheme,
address,
{},
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -841,6 +841,8 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
ref.read(pSendAmount.notifier).state = amount;
}

// Trigger validation after pasting.
_setValidAddressProviders(_address);
setState(() {
_addressToggleFlag = sendToController.text.isNotEmpty;
});
Expand All @@ -856,6 +858,7 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
sendToController.text = content;
_address = content;

// Trigger validation after pasting.
_setValidAddressProviders(_address);
setState(() {
_addressToggleFlag = sendToController.text.isNotEmpty;
Expand Down
191 changes: 167 additions & 24 deletions lib/utilities/address_utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,19 @@

import 'dart:convert';

import 'logger.dart';
import '../wallets/crypto_currency/crypto_currency.dart';

class AddressUtils {
static final Set<String> recognizedParams = {'amount', 'label', 'message'};
static final Set<String> recognizedParams = {
'amount',
'label',
'message',
'tx_amount', // For Monero/Wownero.
'tx_payment_id',
'recipient_name',
'tx_description',
// TODO [prio=med]: Add more recognized params for other coins.
};

static String condenseAddress(String address) {
return '${address.substring(0, 5)}...${address.substring(address.length - 5)}';
Expand Down Expand Up @@ -178,43 +186,144 @@ class AddressUtils {
.where((entry) => recognizedParams.contains(entry.key.toLowerCase())));
}

/// Parses a URI string.
/// Parses a URI string and returns a map with parsed components.
static Map<String, String> parseUri(String uri) {
final Map<String, String> result = {};
try {
final u = Uri.parse(uri);
if (u.hasScheme) {
result["scheme"] = u.scheme.toLowerCase();
result["address"] = u.path;
result.addAll(u.queryParameters);

// Handle different URI formats.
if (result["scheme"] == "bitcoin" ||
result["scheme"] == "bitcoincash") {
result["address"] = u.path;
} else if (result["scheme"] == "monero") {
// Monero addresses can contain '?' which Uri.parse interprets as query start.
final addressEnd =
uri.indexOf('?', 7); // 7 is the length of "monero:".
if (addressEnd != -1) {
result["address"] = uri.substring(7, addressEnd);
} else {
result["address"] = uri.substring(7);
}
} else {
// Default case, treat path as address.
result["address"] = u.path;
}

// Parse query parameters.
result.addAll(_parseQueryParameters(u.queryParameters));

// Handle Monero-specific fragment (tx_description).
if (u.fragment.isNotEmpty && result["scheme"] == "monero") {
result["tx_description"] = Uri.decodeComponent(u.fragment);
}
}
} catch (e) {
Logging.instance
.log("Exception caught in parseUri($uri): $e", level: LogLevel.Error);
print("Exception caught in parseUri($uri): $e");
}
return result;
}

/// builds a uri string with the given address and query parameters if any
/// Helper method to parse and normalize query parameters.
static Map<String, String> _parseQueryParameters(Map<String, String> params) {
final Map<String, String> result = {};
params.forEach((key, value) {
final lowerKey = key.toLowerCase();
if (recognizedParams.contains(lowerKey)) {
switch (lowerKey) {
case 'amount':
case 'tx_amount':
result['amount'] = _normalizeAmount(value);
break;
case 'label':
case 'recipient_name':
result['label'] = Uri.decodeComponent(value);
break;
case 'message':
case 'tx_description':
result['message'] = Uri.decodeComponent(value);
break;
case 'tx_payment_id':
result['tx_payment_id'] = Uri.decodeComponent(value);
break;
default:
result[lowerKey] = Uri.decodeComponent(value);
}
} else {
// Include unrecognized parameters as-is.
result[key] = Uri.decodeComponent(value);
}
});
return result;
}

/// Normalizes amount value to a standard format.
static String _normalizeAmount(String amount) {
// Remove any non-numeric characters except for '.'
final sanitized = amount.replaceAll(RegExp(r'[^\d.]'), '');
// Ensure only one decimal point
final parts = sanitized.split('.');
if (parts.length > 2) {
return '${parts[0]}.${parts.sublist(1).join()}';
}
return sanitized;
}

/// Centralized method to handle various cryptocurrency URIs and return a common object.
static PaymentUriData parsePaymentUri(String uri) {
final Map<String, String> parsedData = parseUri(uri);

// Normalize the URI scheme.
final String scheme = parsedData['scheme'] ?? '';
parsedData.remove('scheme');

// Determine the coin type based on the URI scheme.
final CryptoCurrency coin = _getCryptoCurrencyByScheme(scheme);

// Filter out unrecognized parameters.
final filteredParams = filterParams(parsedData);

return PaymentUriData(
coin: coin,
address: parsedData['address'] ?? '',
amount: filteredParams['amount'] ?? filteredParams['tx_amount'],
label: filteredParams['label'] ?? filteredParams['recipient_name'],
message: filteredParams['message'] ?? filteredParams['tx_description'],
paymentId: filteredParams['tx_payment_id'], // Specific to Monero
additionalParams: filteredParams,
);
}

/// Builds a uri string with the given address and query parameters (if any)
static String buildUriString(
CryptoCurrency coin,
String scheme,
String address,
Map<String, String> params,
) {
// TODO: other sanitation as well ?
String sanitizedAddress = address;
if (coin is Bitcoincash || coin is Ecash) {
final prefix = "${coin.uriScheme}:";
if (address.startsWith(prefix)) {
sanitizedAddress = address.replaceFirst(prefix, "");
}
}
// Filter unrecognized parameters.
final filteredParams = filterParams(params);
String uriString = "${coin.uriScheme}:$sanitizedAddress";
if (filteredParams.isNotEmpty) {
uriString += Uri(queryParameters: filteredParams).toString();
String uriString = "$scheme:$address";

if (scheme.toLowerCase() == "monero") {
// Handle Monero-specific formatting.
if (filteredParams.containsKey("tx_description")) {
final description = filteredParams.remove("tx_description")!;
if (filteredParams.isNotEmpty) {
uriString += Uri(queryParameters: filteredParams).toString();
}
uriString += "#${Uri.encodeComponent(description)}";
} else if (filteredParams.isNotEmpty) {
uriString += Uri(queryParameters: filteredParams).toString();
}
} else {
// General case for other cryptocurrencies.
if (filteredParams.isNotEmpty) {
uriString += Uri(queryParameters: filteredParams).toString();
}
}

return uriString;
}

Expand All @@ -224,10 +333,7 @@ class AddressUtils {
try {
result = Map<String, dynamic>.from(jsonDecode(data) as Map);
} catch (e) {
Logging.instance.log(
"Exception caught in parseQRSeedData($data): $e",
level: LogLevel.Error,
);
print("Exception caught in parseQRSeedData($data): $e");
}
return result;
}
Expand All @@ -236,4 +342,41 @@ class AddressUtils {
static String encodeQRSeedData(List<String> words) {
return jsonEncode({"mnemonic": words});
}

/// Method to get CryptoCurrency based on URI scheme.
static CryptoCurrency _getCryptoCurrencyByScheme(String scheme) {
switch (scheme) {
case 'bitcoin':
return Bitcoin(CryptoCurrencyNetwork.main);
case 'bitcoincash':
return Bitcoincash(CryptoCurrencyNetwork.main);
case 'ethereum':
return Ethereum(CryptoCurrencyNetwork.main);
case 'monero':
return Monero(CryptoCurrencyNetwork.main);
// Add more cases as needed for other coins
default:
throw UnsupportedError('Unsupported URI scheme: $scheme');
}
}
}

class PaymentUriData {
final CryptoCurrency coin;
final String address;
final String? amount;
final String? label;
final String? message;
final String? paymentId; // Specific to Monero.
final Map<String, String> additionalParams;

PaymentUriData({
required this.coin,
required this.address,
this.amount,
this.label,
this.message,
this.paymentId,
required this.additionalParams,
});
}

0 comments on commit b73628d

Please sign in to comment.