diff --git a/lib/pages/receive_view/addresses/address_card.dart b/lib/pages/receive_view/addresses/address_card.dart index db14fd979..59074d979 100644 --- a/lib/pages/receive_view/addresses/address_card.dart +++ b/lib/pages/receive_view/addresses/address_card.dart @@ -304,7 +304,7 @@ class _AddressCardState extends ConsumerState { key: _qrKey, child: QR( data: AddressUtils.buildUriString( - widget.coin, + widget.coin.uriScheme, address.value, {}, ), diff --git a/lib/pages/receive_view/addresses/address_details_view.dart b/lib/pages/receive_view/addresses/address_details_view.dart index 2b0e09187..2006e900b 100644 --- a/lib/pages/receive_view/addresses/address_details_view.dart +++ b/lib/pages/receive_view/addresses/address_details_view.dart @@ -97,7 +97,7 @@ class _AddressDetailsViewState extends ConsumerState { key: _qrKey, child: QR( data: AddressUtils.buildUriString( - ref.watch(pWalletCoin(widget.walletId)), + ref.watch(pWalletCoin(widget.walletId)).uriScheme, address.value, {}, ), @@ -289,7 +289,7 @@ class _AddressDetailsViewState extends ConsumerState { key: _qrKey, child: QR( data: AddressUtils.buildUriString( - coin, + coin.uriScheme, address.value, {}, ), diff --git a/lib/pages/receive_view/addresses/address_qr_popup.dart b/lib/pages/receive_view/addresses/address_qr_popup.dart index 5a8bc1592..b4446f543 100644 --- a/lib/pages/receive_view/addresses/address_qr_popup.dart +++ b/lib/pages/receive_view/addresses/address_qr_popup.dart @@ -142,7 +142,7 @@ class _AddressQrPopupState extends State { key: _qrKey, child: QR( data: AddressUtils.buildUriString( - widget.coin, + widget.coin.uriScheme, widget.addressString, {}, ), diff --git a/lib/pages/receive_view/generate_receiving_uri_qr_code_view.dart b/lib/pages/receive_view/generate_receiving_uri_qr_code_view.dart index 7a7497d0e..f9049a538 100644 --- a/lib/pages/receive_view/generate_receiving_uri_qr_code_view.dart +++ b/lib/pages/receive_view/generate_receiving_uri_qr_code_view.dart @@ -170,7 +170,7 @@ class _GenerateUriQrCodeViewState extends State { } final uriString = AddressUtils.buildUriString( - widget.coin, + widget.coin.uriScheme, receivingAddress, queryParams, ); @@ -263,7 +263,7 @@ class _GenerateUriQrCodeViewState extends State { } _uriString = AddressUtils.buildUriString( - widget.coin, + widget.coin.uriScheme, receivingAddress, {}, ); diff --git a/lib/pages/receive_view/receive_view.dart b/lib/pages/receive_view/receive_view.dart index 2beaab5f2..a40262cdc 100644 --- a/lib/pages/receive_view/receive_view.dart +++ b/lib/pages/receive_view/receive_view.dart @@ -577,7 +577,7 @@ class _ReceiveViewState extends ConsumerState { children: [ QR( data: AddressUtils.buildUriString( - coin, + coin.uriScheme, address, {}, ), diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 0eb6f8881..004b02af1 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -163,25 +163,23 @@ class _SendViewState extends ConsumerState { level: LogLevel.Info, ); - final results = AddressUtils.parseUri(qrResult.rawContent); + final paymentData = AddressUtils.parsePaymentUri(qrResult.rawContent); - Logging.instance.log("qrResult parsed: $results", level: LogLevel.Info); - - if (results.isNotEmpty && results["scheme"] == coin.uriScheme) { + if (paymentData.coin.uriScheme == coin.uriScheme) { // auto fill address - _address = (results["address"] ?? "").trim(); + _address = paymentData.address.trim(); sendToController.text = _address!; // autofill notes field - if (results["message"] != null) { - noteController.text = results["message"]!; - } else if (results["label"] != null) { - noteController.text = results["label"]!; + if (paymentData.message != null) { + noteController.text = paymentData.message!; + } else if (paymentData.label != null) { + noteController.text = paymentData.label!; } // autofill amount field - if (results["amount"] != null) { - final Amount amount = Decimal.parse(results["amount"]!).toAmount( + if (paymentData.amount != null) { + final Amount amount = Decimal.parse(paymentData.amount!).toAmount( fractionDigits: coin.fractionDigits, ); cryptoAmountController.text = ref.read(pAmountFormatter(coin)).format( @@ -1071,7 +1069,7 @@ class _SendViewState extends ConsumerState { _address = _address!.substring(0, _address!.indexOf("\n")); } - sendToController.text = formatAddress(_address!); + sendToController.text = AddressUtils().formatAddress(_address!); } }); } @@ -1402,7 +1400,8 @@ class _SendViewState extends ConsumerState { if (coin is Epiccash) { // strip http:// and https:// if content contains @ - content = formatAddress( + content = AddressUtils() + .formatAddress( content, ); } @@ -2421,22 +2420,3 @@ class _SendViewState extends ConsumerState { ); } } - -String formatAddress(String epicAddress) { - // strip http:// or https:// prefixes if the address contains an @ symbol (and is thus an epicbox address) - if ((epicAddress.startsWith("http://") || - epicAddress.startsWith("https://")) && - epicAddress.contains("@")) { - epicAddress = epicAddress.replaceAll("http://", ""); - epicAddress = epicAddress.replaceAll("https://", ""); - } - // strip mailto: prefix - if (epicAddress.startsWith("mailto:")) { - epicAddress = epicAddress.replaceAll("mailto:", ""); - } - // strip / suffix if the address contains an @ symbol (and is thus an epicbox address) - if (epicAddress.endsWith("/") && epicAddress.contains("@")) { - epicAddress = epicAddress.substring(0, epicAddress.length - 1); - } - return epicAddress; -} diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart index a792641ba..a96a01b8f 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart @@ -448,7 +448,7 @@ class _DesktopReceiveState extends ConsumerState { Center( child: QR( data: AddressUtils.buildUriString( - coin, + coin.uriScheme, address, {}, ), diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart index 6fa008109..b09b57ef0 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart @@ -730,13 +730,15 @@ class _DesktopSendState extends ConsumerState { void _processQrCodeData(String qrCodeData) { try { - var results = AddressUtils.parseUri(qrCodeData); - if (results.isNotEmpty && results["scheme"] == coin.uriScheme) { - _address = (results["address"] ?? "").trim(); + final paymentData = AddressUtils.parsePaymentUri(qrCodeData); + if (paymentData.coin.uriScheme == coin.uriScheme) { + // Auto fill address. + _address = paymentData.address.trim(); sendToController.text = _address!; - if (results["amount"] != null) { - final Amount amount = Decimal.parse(results["amount"]!).toAmount( + // Amount. + if (paymentData.amount != null) { + final Amount amount = Decimal.parse(paymentData.amount!).toAmount( fractionDigits: coin.fractionDigits, ); cryptoAmountController.text = ref.read(pAmountFormatter(coin)).format( @@ -746,6 +748,13 @@ class _DesktopSendState extends ConsumerState { ref.read(pSendAmount.notifier).state = amount; } + // Note/message. + if (paymentData.message != null) { + _note = paymentData.message; + } else if (paymentData.label != null) { + _note = paymentData.label; + } + _setValidAddressProviders(_address); setState(() { _addressToggleFlag = sendToController.text.isNotEmpty; @@ -796,18 +805,65 @@ class _DesktopSendState extends ConsumerState { content = content.substring(0, content.indexOf("\n")); } - if (coin is Epiccash) { - // strip http:// and https:// if content contains @ - content = formatAddress(content); + try { + final paymentData = AddressUtils.parsePaymentUri(content); + if (paymentData.coin.uriScheme == coin.uriScheme) { + // auto fill address + _address = paymentData.address; + sendToController.text = _address!; + + // autofill notes field. + if (paymentData.message != null) { + _note = paymentData.message; + } else if (paymentData.label != null) { + _note = paymentData.label; + } + + // autofill amoutn field + if (paymentData.amount != null) { + final amount = Decimal.parse(paymentData.amount!).toAmount( + fractionDigits: coin.fractionDigits, + ); + cryptoAmountController.text = ref + .read(pAmountFormatter(coin)) + .format(amount, withUnitName: false); + ref.read(pSendAmount.notifier).state = amount; + } + + // Trigger validation after pasting. + _setValidAddressProviders(_address); + setState(() { + _addressToggleFlag = sendToController.text.isNotEmpty; + }); + } else { + if (coin is Epiccash) { + content = AddressUtils().formatAddress(content); + } + + sendToController.text = content; + _address = content; + + _setValidAddressProviders(_address); + setState(() { + _addressToggleFlag = sendToController.text.isNotEmpty; + }); + } + } catch (e) { + // If parsing fails, treat it as a plain address. + if (coin is Epiccash) { + // strip http:// and https:// if content contains @ + content = AddressUtils().formatAddress(content); + } + + sendToController.text = content; + _address = content; + + // Trigger validation after pasting. + _setValidAddressProviders(_address); + setState(() { + _addressToggleFlag = sendToController.text.isNotEmpty; + }); } - - sendToController.text = content; - _address = content; - - _setValidAddressProviders(_address); - setState(() { - _addressToggleFlag = sendToController.text.isNotEmpty; - }); } } diff --git a/lib/utilities/address_utils.dart b/lib/utilities/address_utils.dart index 97f2ec965..869aa5895 100644 --- a/lib/utilities/address_utils.dart +++ b/lib/utilities/address_utils.dart @@ -10,10 +10,21 @@ import 'dart:convert'; -import 'logger.dart'; +import '../app_config.dart'; import '../wallets/crypto_currency/crypto_currency.dart'; class AddressUtils { + static final Set 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)}'; } @@ -170,42 +181,150 @@ class AddressUtils { // // } // } - /// parse an address uri - /// returns an empty map if the input string does not begin with "firo:" + /// Return only recognized parameters. + static Map filterParams(Map params) { + return Map.fromEntries(params.entries + .where((entry) => recognizedParams.contains(entry.key.toLowerCase()))); + } + + /// Parses a URI string and returns a map with parsed components. static Map parseUri(String uri) { final Map 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 _parseQueryParameters(Map params) { + final Map 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 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 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 = "$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(); } } - String uriString = "${coin.uriScheme}:$sanitizedAddress"; - if (params.isNotEmpty) { - uriString += Uri(queryParameters: params).toString(); - } + return uriString; } @@ -215,10 +334,7 @@ class AddressUtils { try { result = Map.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; } @@ -227,4 +343,53 @@ class AddressUtils { static String encodeQRSeedData(List words) { return jsonEncode({"mnemonic": words}); } + + /// Method to get CryptoCurrency based on URI scheme. + static CryptoCurrency _getCryptoCurrencyByScheme(String scheme) { + if (AppConfig.coins.map((e) => e.uriScheme).toSet().contains(scheme)) { + return AppConfig.coins.firstWhere((e) => e.uriScheme == scheme); + } else { + throw UnsupportedError('Unsupported URI scheme: $scheme'); + } + } + + /// Formats an address string to remove any unnecessary prefixes or suffixes. + String formatAddress(String epicAddress) { + // strip http:// or https:// prefixes if the address contains an @ symbol (and is thus an epicbox address) + if ((epicAddress.startsWith("http://") || + epicAddress.startsWith("https://")) && + epicAddress.contains("@")) { + epicAddress = epicAddress.replaceAll("http://", ""); + epicAddress = epicAddress.replaceAll("https://", ""); + } + // strip mailto: prefix + if (epicAddress.startsWith("mailto:")) { + epicAddress = epicAddress.replaceAll("mailto:", ""); + } + // strip / suffix if the address contains an @ symbol (and is thus an epicbox address) + if (epicAddress.endsWith("/") && epicAddress.contains("@")) { + epicAddress = epicAddress.substring(0, epicAddress.length - 1); + } + return epicAddress; + } +} + +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 additionalParams; + + PaymentUriData({ + required this.coin, + required this.address, + this.amount, + this.label, + this.message, + this.paymentId, + required this.additionalParams, + }); }