diff --git a/lib/models/send_view_auto_fill_data.dart b/lib/models/send_view_auto_fill_data.dart index bb8817ca2..c97fba40e 100644 --- a/lib/models/send_view_auto_fill_data.dart +++ b/lib/models/send_view_auto_fill_data.dart @@ -10,25 +10,42 @@ import 'package:decimal/decimal.dart'; +import '../services/open_crypto_pay/models.dart'; + class SendViewAutoFillData { final String address; final String contactLabel; final Decimal? amount; final String note; + /// When set, ConfirmTransactionView completes the OpenCryptoPay submission + /// flow for the prepared transaction. + final OpenCryptoPayCommit? openCryptoPayCommit; + SendViewAutoFillData({ required this.address, required this.contactLabel, this.amount, this.note = "", + this.openCryptoPayCommit, }); Map toJson() { + final commit = openCryptoPayCommit; return { "address": address, "contactLabel": contactLabel, "amount": amount, "note": note, + if (commit != null) + "openCryptoPayCommit": { + "method": commit.method, + "asset": commit.asset, + "submissionFlow": commit.submissionFlow.name, + "quoteId": commit.quoteId, + "paymentId": commit.paymentId, + "expiresAt": commit.expiresAt.toIso8601String(), + }, }; } diff --git a/lib/pages/open_crypto_pay/open_crypto_pay_confirm_view.dart b/lib/pages/open_crypto_pay/open_crypto_pay_confirm_view.dart new file mode 100644 index 000000000..9c3b0a4ac --- /dev/null +++ b/lib/pages/open_crypto_pay/open_crypto_pay_confirm_view.dart @@ -0,0 +1,548 @@ +import 'dart:async'; + +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:tuple/tuple.dart'; + +import '../../models/isar/models/ethereum/eth_contract.dart'; +import '../../models/send_view_auto_fill_data.dart'; +import '../../notifications/show_flush_bar.dart'; +import '../../providers/db/main_db_provider.dart'; +import '../../providers/providers.dart'; +import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart'; +import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart'; +import '../../services/open_crypto_pay/erc20_token_lookup.dart'; +import '../../services/open_crypto_pay/evm_uri.dart'; +import '../../services/open_crypto_pay/method_support.dart'; +import '../../services/open_crypto_pay/models.dart'; +import '../../services/open_crypto_pay/open_crypto_pay_api.dart'; +import '../../utilities/address_utils.dart'; +import '../../utilities/logger.dart'; +import '../../utilities/text_styles.dart'; +import '../../wallets/crypto_currency/crypto_currency.dart'; +import '../../wallets/isar/providers/eth/current_token_wallet_provider.dart'; +import '../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../wallets/wallet/impl/ethereum_wallet.dart'; +import '../../wallets/wallet/impl/sub_wallets/eth_token_wallet.dart'; +import '../../wallets/wallet/wallet.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/loading_indicator.dart'; +import '../../widgets/rounded_white_container.dart'; +import 'open_crypto_pay_widgets.dart'; +import '../send_view/send_view.dart'; +import '../send_view/token_send_view.dart'; + +enum OpenCryptoPayConfirmResult { quoteExpired } + +/// Fetches the transaction details for the selected method/asset, shows a +/// summary, then forwards to the standard [SendView] prefilled with the +/// payment address and amount. +class OpenCryptoPayConfirmView extends ConsumerStatefulWidget { + const OpenCryptoPayConfirmView({ + super.key, + required this.paymentDetails, + required this.selectedMethod, + required this.selectedAsset, + required this.walletId, + required this.coin, + this.isDesktop = false, + }); + + final OpenCryptoPayPaymentDetails paymentDetails; + final OpenCryptoPayTransferMethod selectedMethod; + final OpenCryptoPayAsset selectedAsset; + final String walletId; + final CryptoCurrency coin; + final bool isDesktop; + + @override + ConsumerState createState() => + _OpenCryptoPayConfirmViewState(); +} + +class _OpenCryptoPayConfirmViewState + extends ConsumerState { + OpenCryptoPayTransactionDetails? _txDetails; + bool _isLoading = true; + String? _errorMessage; + late final Decimal? _quotedAmount; + + DateTime? get _expiresAt => + _txDetails?.expiryDate ?? widget.paymentDetails.quote?.expiration; + + bool get _isExpired { + final expiresAt = _expiresAt; + return expiresAt != null && expiresAt.isBefore(DateTime.now()); + } + + @override + void initState() { + super.initState(); + _quotedAmount = Decimal.tryParse(widget.selectedAsset.amount); + _fetch(); + } + + Future _fetch() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final quote = widget.paymentDetails.quote; + if (quote == null) { + throw Exception("No quote provided by the payment provider"); + } + _txDetails = await OpenCryptoPayApi.instance.getTransactionDetails( + callbackUrl: widget.paymentDetails.callback, + quoteId: quote.id, + method: widget.selectedMethod.method, + asset: widget.selectedAsset.asset, + ); + } catch (e, s) { + Logging.instance.e( + "OpenCryptoPay tx fetch failed", + error: e, + stackTrace: s, + ); + _errorMessage = 'Failed to fetch transaction details: $e'; + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + ({String? address, Decimal? amount, String? scheme}) _parseTransactionUri( + String uri, + ) { + final evmUri = OpenCryptoPayEvmUri.tryParse(uri); + if (evmUri != null && !evmUri.isTokenTransfer) { + return ( + address: evmUri.targetAddress, + amount: evmUri.isNativeTransfer + ? evmUri.amount(fractionDigits: widget.coin.fractionDigits) + : _quotedAmount, + scheme: evmUri.scheme, + ); + } + + final parsedUri = Uri.tryParse(uri); + final data = AddressUtils.parsePaymentUri(uri, logging: Logging.instance); + var address = data?.address ?? parsedUri?.path; + if (address != null) { + if (address.isEmpty) address = null; + } + final amount = data?.amount != null + ? Decimal.tryParse(data!.amount!) + : _quotedAmount; + return ( + address: address, + amount: amount, + scheme: data?.scheme ?? parsedUri?.scheme, + ); + } + + EthContract? _enabledErc20Token(String contractAddress) { + return OpenCryptoPayErc20TokenLookup.enabledToken( + ref.read(mainDBProvider), + ref.read(pWalletTokenAddresses(widget.walletId)), + contractAddress, + ); + } + + bool _matchesQuotedAmount(Decimal amount) { + final quotedAmount = _quotedAmount; + return quotedAmount != null && amount.compareTo(quotedAmount) == 0; + } + + SendViewAutoFillData _autoFillData({ + required String address, + required Decimal amount, + required DateTime expiresAt, + required String recipient, + required OpenCryptoPaySubmissionFlow submissionFlow, + String? tokenContractAddress, + }) { + return SendViewAutoFillData( + address: address, + contactLabel: recipient, + amount: amount, + note: "OpenCryptoPay: $recipient", + openCryptoPayCommit: OpenCryptoPayCommit( + callbackUrl: widget.paymentDetails.callback, + quoteId: widget.paymentDetails.quote!.id, + paymentId: widget.paymentDetails.quote!.paymentId, + method: widget.selectedMethod.method, + asset: widget.selectedAsset.asset, + expiresAt: expiresAt, + submissionFlow: submissionFlow, + minFee: widget.selectedMethod.minFee, + recipientAddress: address, + amount: amount, + tokenContractAddress: tokenContractAddress, + ), + ); + } + + Future _loadTokenWallet(EthContract contract) async { + final wallet = ref.read(pWallets).getWallet(widget.walletId); + if (wallet is! EthereumWallet) { + throw Exception("Ethereum wallet not loaded"); + } + + final old = ref.read(tokenServiceStateProvider); + final tokenWallet = + Wallet.loadTokenWallet(ethWallet: wallet, contract: contract) + as EthTokenWallet; + await tokenWallet.init(); + unawaited(old?.exit()); + ref.read(tokenServiceStateProvider.state).state = tokenWallet; + return tokenWallet; + } + + Future _proceedToSend() async { + if (_isExpired) { + _warn("Quote expired, refreshing..."); + if (mounted) { + Navigator.of(context).pop(OpenCryptoPayConfirmResult.quoteExpired); + } + return; + } + + final uri = _txDetails?.uri; + if (uri == null) { + _warn("No transaction URI provided by the payment provider"); + return; + } + + if (_txDetails?.blockchain != null && + _txDetails!.blockchain != widget.selectedMethod.method) { + _warn("Payment details do not match the selected method"); + return; + } + + final submissionFlow = OpenCryptoPayMethodSupport.submissionFlowFor( + widget.selectedMethod.method, + ); + if (submissionFlow == null) { + _warn("This Open CryptoPay method is not supported yet"); + return; + } + + final expiresAt = _expiresAt; + if (expiresAt == null) { + _warn("No quote expiration provided by the payment provider"); + return; + } + + final recipient = + widget.paymentDetails.recipient?.name ?? + widget.paymentDetails.displayName ?? + "OpenCryptoPay"; + + final evmUri = widget.selectedMethod.method == 'Ethereum' + ? OpenCryptoPayEvmUri.tryParse(uri) + : null; + if (widget.selectedMethod.method == 'Ethereum') { + if (evmUri == null) { + _warn("Could not parse Ethereum payment details"); + return; + } + if (evmUri.chainId != null && evmUri.chainId != 1) { + _warn("Payment URI is for a different Ethereum network"); + return; + } + if (evmUri.functionName != null && !evmUri.isTokenTransfer) { + _warn("Unsupported Ethereum payment request"); + return; + } + if (evmUri.isTokenTransfer) { + // Native ETH may omit chainId, but token calls must be explicit mainnet. + if (evmUri.chainId == null) { + _warn("Payment URI is for a different Ethereum network"); + return; + } + if (widget.selectedAsset.asset.toUpperCase() == + widget.coin.ticker.toUpperCase()) { + _warn("Payment token details are invalid"); + return; + } + await _proceedToTokenSend( + evmUri: evmUri, + expiresAt: expiresAt, + recipient: recipient, + submissionFlow: submissionFlow, + ); + return; + } + if (widget.selectedAsset.asset.toUpperCase() != + widget.coin.ticker.toUpperCase()) { + _warn("Payment token details are invalid"); + return; + } + } + + final parsed = _parseTransactionUri(uri); + if (parsed.address == null) { + _warn("Could not parse payment address"); + return; + } + if (parsed.amount == null) { + _warn("Could not parse payment amount"); + return; + } + if (!_matchesQuotedAmount(parsed.amount!)) { + _warn("Payment amount does not match the quoted amount"); + return; + } + if (parsed.scheme != null && + parsed.scheme!.isNotEmpty && + parsed.scheme != widget.coin.uriScheme) { + _warn("Payment URI does not match this wallet"); + return; + } + + final autoFillData = _autoFillData( + address: parsed.address!, + amount: parsed.amount!, + expiresAt: expiresAt, + recipient: recipient, + submissionFlow: submissionFlow, + ); + + if (!mounted) return; + if (widget.isDesktop) { + await _showDesktopSendForm( + DesktopSend(walletId: widget.walletId, autoFillData: autoFillData), + ); + return; + } + + await Navigator.of(context).pushNamed( + SendView.routeName, + arguments: Tuple3(widget.walletId, widget.coin, autoFillData), + ); + } + + Future _proceedToTokenSend({ + required OpenCryptoPayEvmUri evmUri, + required DateTime expiresAt, + required String recipient, + required OpenCryptoPaySubmissionFlow submissionFlow, + }) async { + final contract = _enabledErc20Token(evmUri.targetAddress); + if (contract == null) { + _warn("This token is not enabled in this wallet"); + return; + } + if (contract.symbol.toUpperCase() != + widget.selectedAsset.asset.toUpperCase()) { + _warn("Payment token does not match the selected asset"); + return; + } + + try { + await _loadTokenWallet(contract); + } catch (e, s) { + Logging.instance.e( + "OpenCryptoPay token wallet load failed", + error: e, + stackTrace: s, + ); + _warn("Could not load token wallet"); + return; + } + + final amount = evmUri.amount(fractionDigits: contract.decimals); + if (!_matchesQuotedAmount(amount)) { + _warn("Payment amount does not match the quoted amount"); + return; + } + + final autoFillData = _autoFillData( + address: evmUri.recipientAddress!, + amount: amount, + expiresAt: expiresAt, + recipient: recipient, + submissionFlow: submissionFlow, + tokenContractAddress: contract.address, + ); + + if (!mounted) return; + if (widget.isDesktop) { + await _showDesktopSendForm( + DesktopTokenSend(walletId: widget.walletId, autoFillData: autoFillData), + ); + return; + } + + await Navigator.of(context).pushNamed( + TokenSendView.routeName, + arguments: Tuple4(widget.walletId, widget.coin, contract, autoFillData), + ); + } + + Future _showDesktopSendForm(Widget child) { + return showOpenCryptoPayDesktopDialog(context: context, child: child); + } + + void _warn(String message) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: message, + context: context, + ), + ); + } + + @override + Widget build(BuildContext context) { + final body = Padding( + padding: const EdgeInsets.all(16), + child: _OpenCryptoPayConfirmBody( + isLoading: _isLoading, + errorMessage: _errorMessage, + paymentDetails: widget.paymentDetails, + selectedMethod: widget.selectedMethod, + selectedAsset: widget.selectedAsset, + txDetails: _txDetails, + onRetry: () => unawaited(_fetch()), + onProceed: () => unawaited(_proceedToSend()), + ), + ); + + return OpenCryptoPayScaffold( + title: "Confirm Payment", + isDesktop: widget.isDesktop, + child: body, + ); + } +} + +class _OpenCryptoPayConfirmBody extends StatelessWidget { + const _OpenCryptoPayConfirmBody({ + required this.isLoading, + required this.errorMessage, + required this.paymentDetails, + required this.selectedMethod, + required this.selectedAsset, + required this.txDetails, + required this.onRetry, + required this.onProceed, + }); + + final bool isLoading; + final String? errorMessage; + final OpenCryptoPayPaymentDetails paymentDetails; + final OpenCryptoPayTransferMethod selectedMethod; + final OpenCryptoPayAsset selectedAsset; + final OpenCryptoPayTransactionDetails? txDetails; + final VoidCallback onRetry; + final VoidCallback onProceed; + + @override + Widget build(BuildContext context) { + if (isLoading) return const Center(child: LoadingIndicator()); + + final error = errorMessage; + if (error != null) { + return OpenCryptoPayErrorView(message: error, onRetry: onRetry); + } + + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _OpenCryptoPaySummaryCard( + paymentDetails: paymentDetails, + selectedMethod: selectedMethod, + selectedAsset: selectedAsset, + ), + if (txDetails?.hint != null) ...[ + const SizedBox(height: 16), + RoundedWhiteContainer( + child: Text(txDetails!.hint!, style: STextStyles.label(context)), + ), + ], + const SizedBox(height: 24), + PrimaryButton(label: "Proceed to Send", onPressed: onProceed), + ], + ), + ); + } +} + +class _OpenCryptoPaySummaryCard extends StatelessWidget { + const _OpenCryptoPaySummaryCard({ + required this.paymentDetails, + required this.selectedMethod, + required this.selectedAsset, + }); + + final OpenCryptoPayPaymentDetails paymentDetails; + final OpenCryptoPayTransferMethod selectedMethod; + final OpenCryptoPayAsset selectedAsset; + + @override + Widget build(BuildContext context) { + return RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Payment Summary", style: STextStyles.itemSubtitle12(context)), + const SizedBox(height: 8), + if (paymentDetails.recipient?.name != null) + _OpenCryptoPaySummaryRow( + label: "To", + value: paymentDetails.recipient!.name!, + ), + if (paymentDetails.requestedAmount != null) + _OpenCryptoPaySummaryRow( + label: "Fiat amount", + value: + "${paymentDetails.requestedAmount!.amount} " + "${paymentDetails.requestedAmount!.asset}", + ), + _OpenCryptoPaySummaryRow( + label: "Crypto amount", + value: "${selectedAsset.amount} ${selectedAsset.asset}", + ), + _OpenCryptoPaySummaryRow( + label: "Network", + value: selectedMethod.method, + ), + ], + ), + ); + } +} + +class _OpenCryptoPaySummaryRow extends StatelessWidget { + const _OpenCryptoPaySummaryRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: STextStyles.label(context)), + const SizedBox(width: 16), + Expanded( + child: Text( + value, + style: STextStyles.itemSubtitle(context), + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/open_crypto_pay/open_crypto_pay_dialog.dart b/lib/pages/open_crypto_pay/open_crypto_pay_dialog.dart new file mode 100644 index 000000000..dfbce7607 --- /dev/null +++ b/lib/pages/open_crypto_pay/open_crypto_pay_dialog.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; + +import '../../wallets/crypto_currency/crypto_currency.dart'; +import 'open_crypto_pay_view.dart'; +import 'open_crypto_pay_widgets.dart'; + +Future showOpenCryptoPayPaymentDesktopDialog({ + required BuildContext context, + required String qrUrl, + required String walletId, + required CryptoCurrency coin, +}) { + return showOpenCryptoPayDesktopDialog( + context: context, + child: OpenCryptoPayView( + qrUrl: qrUrl, + walletId: walletId, + coin: coin, + isDesktop: true, + ), + ); +} diff --git a/lib/pages/open_crypto_pay/open_crypto_pay_view.dart b/lib/pages/open_crypto_pay/open_crypto_pay_view.dart new file mode 100644 index 000000000..01d626236 --- /dev/null +++ b/lib/pages/open_crypto_pay/open_crypto_pay_view.dart @@ -0,0 +1,377 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../../models/isar/models/ethereum/eth_contract.dart'; +import '../../notifications/show_flush_bar.dart'; +import '../../providers/db/main_db_provider.dart'; +import '../../services/open_crypto_pay/erc20_token_lookup.dart'; +import '../../services/open_crypto_pay/method_support.dart'; +import '../../services/open_crypto_pay/models.dart'; +import '../../services/open_crypto_pay/open_crypto_pay_api.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/assets.dart'; +import '../../utilities/logger.dart'; +import '../../utilities/text_styles.dart'; +import '../../wallets/crypto_currency/crypto_currency.dart'; +import '../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../widgets/loading_indicator.dart'; +import '../../widgets/rounded_white_container.dart'; +import 'open_crypto_pay_confirm_view.dart'; +import 'open_crypto_pay_widgets.dart'; + +typedef _OpenCryptoPayOptionSupported = + bool Function( + OpenCryptoPayTransferMethod method, + OpenCryptoPayAsset asset, + Iterable enabledErc20Tokens, + ); + +typedef _OpenCryptoPayOptionSelected = + void Function(OpenCryptoPayTransferMethod method, OpenCryptoPayAsset asset); + +/// Shows the payment details from an Open CryptoPay QR code and lets the user +/// choose a payment method/asset that is supported by this wallet. +class OpenCryptoPayView extends ConsumerStatefulWidget { + const OpenCryptoPayView({ + super.key, + required this.qrUrl, + required this.walletId, + required this.coin, + this.isDesktop = false, + }); + + static const String routeName = "/openCryptoPayView"; + + final String qrUrl; + + /// Only methods/assets this wallet can safely settle are offered. + final String walletId; + final CryptoCurrency coin; + final bool isDesktop; + + @override + ConsumerState createState() => _OpenCryptoPayViewState(); +} + +class _OpenCryptoPayViewState extends ConsumerState { + OpenCryptoPayPaymentDetails? _details; + bool _isLoading = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _fetch(); + } + + Future _fetch() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final details = await OpenCryptoPayApi.instance.getPaymentDetails( + widget.qrUrl, + ); + if (mounted) setState(() => _details = details); + } on OpenCryptoPayNoPendingPaymentException catch (e) { + if (mounted) setState(() => _errorMessage = e.message); + } catch (e, s) { + Logging.instance.e("OpenCryptoPay fetch failed", error: e, stackTrace: s); + if (mounted) setState(() => _errorMessage = 'Failed to fetch: $e'); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + bool _isSupportedOption( + OpenCryptoPayTransferMethod method, + OpenCryptoPayAsset asset, + Iterable enabledErc20Tokens, + ) { + return OpenCryptoPayMethodSupport.isSupportedWalletOption( + coin: widget.coin, + method: method, + asset: asset, + enabledErc20Symbols: enabledErc20Tokens.map((e) => e.symbol), + ); + } + + List _enabledErc20Tokens() { + if (widget.coin is! Ethereum) return const []; + return OpenCryptoPayErc20TokenLookup.enabledTokens( + ref.watch(mainDBProvider), + ref.watch(pWalletTokenAddresses(widget.walletId)), + ); + } + + Future _onSelected( + OpenCryptoPayTransferMethod method, + OpenCryptoPayAsset asset, + ) async { + final quote = _details?.quote; + if (quote == null) return; + + if (quote.isExpired) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Quote expired, refreshing...", + context: context, + ), + ); + await _fetch(); + return; + } + + final confirmView = OpenCryptoPayConfirmView( + paymentDetails: _details!, + selectedMethod: method, + selectedAsset: asset, + walletId: widget.walletId, + coin: widget.coin, + isDesktop: widget.isDesktop, + ); + final result = widget.isDesktop + ? await showOpenCryptoPayDesktopDialog( + context: context, + child: confirmView, + ) + : await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => confirmView, + ), + ); + + if (result == OpenCryptoPayConfirmResult.quoteExpired && mounted) { + await _fetch(); + } + } + + @override + Widget build(BuildContext context) { + final body = Padding( + padding: const EdgeInsets.all(16), + child: _OpenCryptoPayBody( + isLoading: _isLoading, + errorMessage: _errorMessage, + details: _details, + coin: widget.coin, + enabledErc20Tokens: _details == null ? const [] : _enabledErc20Tokens(), + isSupportedOption: _isSupportedOption, + onRetry: () => unawaited(_fetch()), + onSelected: (method, asset) => unawaited(_onSelected(method, asset)), + ), + ); + + return OpenCryptoPayScaffold( + title: "Open CryptoPay", + isDesktop: widget.isDesktop, + child: body, + ); + } +} + +class _OpenCryptoPayBody extends StatelessWidget { + const _OpenCryptoPayBody({ + required this.isLoading, + required this.errorMessage, + required this.details, + required this.coin, + required this.enabledErc20Tokens, + required this.isSupportedOption, + required this.onRetry, + required this.onSelected, + }); + + final bool isLoading; + final String? errorMessage; + final OpenCryptoPayPaymentDetails? details; + final CryptoCurrency coin; + final List enabledErc20Tokens; + final _OpenCryptoPayOptionSupported isSupportedOption; + final VoidCallback onRetry; + final _OpenCryptoPayOptionSelected onSelected; + + @override + Widget build(BuildContext context) { + if (isLoading) return const Center(child: LoadingIndicator()); + + final error = errorMessage; + if (error != null) { + return OpenCryptoPayErrorView(message: error, onRetry: onRetry); + } + + final details = this.details; + if (details == null) { + return const Center(child: Text("No payment data")); + } + + final options = [ + for (final m in details.availableMethods) + for (final a in m.assets) + if (isSupportedOption(m, a, enabledErc20Tokens)) + (method: m, asset: a), + ]; + + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (details.recipient != null) ...[ + _OpenCryptoPayRecipientCard(recipient: details.recipient!), + const SizedBox(height: 16), + ], + if (details.requestedAmount != null) ...[ + _OpenCryptoPayAmountCard(details: details), + const SizedBox(height: 16), + ], + Text( + "Select Payment Method", + style: STextStyles.pageTitleH2(context), + ), + const SizedBox(height: 8), + if (options.isEmpty) + RoundedWhiteContainer( + child: Text( + "No supported Open CryptoPay option available for " + "${coin.prettyName}.", + style: STextStyles.itemSubtitle(context), + ), + ) + else + ...options.map( + (o) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _OpenCryptoPayMethodCard( + method: o.method, + asset: o.asset, + onTap: () => onSelected(o.method, o.asset), + ), + ), + ), + if (details.quote != null) ...[ + const SizedBox(height: 8), + Text( + "Quote expires: ${details.quote!.expiration.toLocal()}", + style: STextStyles.label(context), + ), + ], + ], + ), + ); + } +} + +class _OpenCryptoPayRecipientCard extends StatelessWidget { + const _OpenCryptoPayRecipientCard({required this.recipient}); + + final OpenCryptoPayRecipient recipient; + + @override + Widget build(BuildContext context) { + return RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Recipient", style: STextStyles.itemSubtitle12(context)), + if (recipient.name != null) ...[ + const SizedBox(height: 4), + Text(recipient.name!, style: STextStyles.titleBold12(context)), + ], + if (recipient.formattedAddress.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + recipient.formattedAddress, + style: STextStyles.itemSubtitle(context), + ), + ], + ], + ), + ); + } +} + +class _OpenCryptoPayAmountCard extends StatelessWidget { + const _OpenCryptoPayAmountCard({required this.details}); + + final OpenCryptoPayPaymentDetails details; + + @override + Widget build(BuildContext context) { + return RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Amount Due", style: STextStyles.itemSubtitle12(context)), + const SizedBox(height: 4), + Text( + "${details.requestedAmount!.amount} ${details.requestedAmount!.asset}", + style: STextStyles.pageTitleH2(context), + ), + if (details.displayName != null) ...[ + const SizedBox(height: 4), + Text( + details.displayName!, + style: STextStyles.itemSubtitle(context), + ), + ], + ], + ), + ); + } +} + +class _OpenCryptoPayMethodCard extends StatelessWidget { + const _OpenCryptoPayMethodCard({ + required this.method, + required this.asset, + required this.onTap, + }); + + final OpenCryptoPayTransferMethod method; + final OpenCryptoPayAsset asset; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return RoundedWhiteContainer( + onPressed: onTap, + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "${asset.amount} ${asset.asset}", + style: STextStyles.titleBold12(context), + ), + const SizedBox(height: 2), + Text( + "via ${method.method}", + style: STextStyles.itemSubtitle(context), + ), + ], + ), + ), + SvgPicture.asset( + Assets.svg.chevronRight, + width: 20, + height: 20, + colorFilter: ColorFilter.mode( + Theme.of( + context, + ).extension()!.textFieldDefaultSearchIconLeft, + BlendMode.srcIn, + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/open_crypto_pay/open_crypto_pay_widgets.dart b/lib/pages/open_crypto_pay/open_crypto_pay_widgets.dart new file mode 100644 index 000000000..e86dc62bb --- /dev/null +++ b/lib/pages/open_crypto_pay/open_crypto_pay_widgets.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; + +import '../../themes/stack_colors.dart'; +import '../../utilities/text_styles.dart'; +import '../../widgets/background.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/desktop/desktop_dialog.dart'; +import '../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../widgets/desktop/primary_button.dart'; + +Future showOpenCryptoPayDesktopDialog({ + required BuildContext context, + required Widget child, +}) { + return showDialog( + context: context, + barrierDismissible: true, + builder: (_) => DesktopDialog( + maxHeight: MediaQuery.sizeOf(context).height - 64, + maxWidth: 580, + child: child, + ), + ); +} + +class OpenCryptoPayScaffold extends StatelessWidget { + const OpenCryptoPayScaffold({ + super.key, + required this.title, + required this.isDesktop, + required this.child, + }); + + final String title; + final bool isDesktop; + final Widget child; + + @override + Widget build(BuildContext context) { + if (isDesktop) return OpenCryptoPayDesktopFrame(title: title, child: child); + + final colors = Theme.of(context).extension()!; + return Background( + child: Scaffold( + backgroundColor: colors.background, + appBar: AppBar( + backgroundColor: colors.backgroundAppBar, + leading: const AppBarBackButton(), + title: Text(title, style: STextStyles.navBarTitle(context)), + ), + body: SafeArea(child: child), + ), + ); + } +} + +class OpenCryptoPayDesktopFrame extends StatelessWidget { + const OpenCryptoPayDesktopFrame({ + super.key, + required this.title, + required this.child, + }); + + final String title; + final Widget child; + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height - 64, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text(title, style: STextStyles.desktopH3(context)), + ), + const DesktopDialogCloseButton(), + ], + ), + Flexible(child: child), + ], + ), + ); + } +} + +class OpenCryptoPayErrorView extends StatelessWidget { + const OpenCryptoPayErrorView({ + super.key, + required this.message, + required this.onRetry, + }); + + final String message; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + message, + style: STextStyles.itemSubtitle(context), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + PrimaryButton(label: "Retry", onPressed: onRetry), + ], + ), + ); + } +} diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index 8f7eab592..11e540212 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -18,8 +18,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; import 'package:isar_community/isar.dart'; -import '../../models/isar/models/isar_models.dart'; import '../../models/input.dart'; +import '../../models/isar/models/isar_models.dart'; import '../../models/isar/models/transaction_note.dart'; import '../../models/isar/ordinal.dart'; import '../../notifications/show_flush_bar.dart'; @@ -29,6 +29,8 @@ import '../../providers/global/global_nav_key_provider.dart'; import '../../providers/providers.dart'; import '../../providers/wallet/public_private_balance_state_provider.dart'; import '../../route_generator.dart'; +import '../../services/open_crypto_pay/models.dart'; +import '../../services/open_crypto_pay/settlement.dart'; import '../../themes/stack_colors.dart'; import '../../themes/theme_providers.dart'; import '../../utilities/amount/amount.dart'; @@ -51,6 +53,7 @@ import '../../wallets/wallet/impl/mimblewimblecoin_wallet.dart'; import '../../wallets/wallet/impl/solana_wallet.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/ordinals_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart'; +import '../../wallets/wallet/wallet.dart'; import '../../widgets/background.dart'; import '../../widgets/conditional_parent.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; @@ -84,6 +87,7 @@ class ConfirmTransactionView extends ConsumerStatefulWidget { this.isPaynymNotificationTransaction = false, this.isTokenTx = false, this.onSuccessInsteadOfRouteOnSuccess, + this.openCryptoPayCommit, }); static const String routeName = "/confirmTransactionView"; @@ -97,6 +101,7 @@ class ConfirmTransactionView extends ConsumerStatefulWidget { final bool isTokenTx; final VoidCallback? onSuccessInsteadOfRouteOnSuccess; final VoidCallback onSuccess; + final OpenCryptoPayCommit? openCryptoPayCommit; @override ConsumerState createState() => @@ -406,6 +411,45 @@ class _ConfirmTransactionViewState Future _attemptSend(BuildContext context) async { final wallet = ref.read(pWallets).getWallet(walletId); final coin = wallet.info.coin; + final openCryptoPayCommit = widget.openCryptoPayCommit; + + if (openCryptoPayCommit?.isExpired ?? false) { + if (context.mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Open CryptoPay quote expired. Please scan again.", + context: context, + ), + ); + } + return; + } + + final openCryptoPaySettlement = openCryptoPayCommit == null + ? null + : OpenCryptoPaySettlement( + wallet: wallet, + txData: widget.txData, + commit: openCryptoPayCommit, + mainDB: ref.read(mainDBProvider), + isTokenTx: widget.isTokenTx, + tokenWallet: ref.read(pCurrentTokenWallet), + ); + + final openCryptoPayError = openCryptoPaySettlement?.validate(); + if (openCryptoPayError != null) { + if (context.mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: openCryptoPayError, + context: context, + ), + ); + } + return; + } final sendProgressController = ProgressAndSuccessController(); @@ -429,9 +473,16 @@ class _ConfirmTransactionViewState Future txDataFuture; final note = noteController.text; + final openCryptoPayTxIdFlow = + openCryptoPaySettlement?.shouldCommitTxId ?? false; try { - if (widget.isTokenTx) { + if (openCryptoPaySettlement?.shouldSubmitRawHex ?? false) { + final submitWallet = widget.isTokenTx + ? ref.read(pCurrentTokenWallet)! + : wallet; + txDataFuture = openCryptoPaySettlement!.submitRawHex(submitWallet); + } else if (widget.isTokenTx) { if (wallet is SolanaWallet) { // For Solana tokens, use the Solana token wallet. txDataFuture = ref @@ -522,14 +573,19 @@ class _ConfirmTransactionViewState final results = await Future.wait([txDataFuture, time]); final confirmedTx = results.first as TxData; - sendProgressController.triggerSuccess?.call(); - await Future.delayed(const Duration(seconds: 5)); - if (wallet is FiroWallet && confirmedTx.sparkMints != null) { txids.addAll(confirmedTx.sparkMints!.map((e) => e.txid!)); } else { txids.add(confirmedTx.txid!); } + + if (openCryptoPayTxIdFlow) { + final result = results.first as TxData; + await openCryptoPaySettlement!.commitTxId(result); + } + + sendProgressController.triggerSuccess?.call(); + await Future.delayed(const Duration(seconds: 5)); if (coin is! Ethereum) { ref.refresh(desktopUseUTXOs); } @@ -759,7 +815,9 @@ class _ConfirmTransactionViewState return; } } catch (e, s) { - const message = "Broadcast transaction failed"; + final message = widget.openCryptoPayCommit == null + ? "Broadcast transaction failed" + : "Open CryptoPay payment failed"; Logging.instance.e(message, error: e, stackTrace: s); // pop sending dialog if (context.mounted) { diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index a2dd4f483..5d230c7ed 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -29,6 +29,7 @@ import '../../providers/ui/fee_rate_type_state_provider.dart'; import '../../providers/ui/preview_tx_button_state_provider.dart'; import '../../providers/wallet/public_private_balance_state_provider.dart'; import '../../route_generator.dart'; +import '../../services/open_crypto_pay/lnurl_utils.dart'; import '../../services/spark_names_service.dart'; import '../../themes/coin_icon_provider.dart'; import '../../themes/stack_colors.dart'; @@ -82,6 +83,7 @@ import '../../widgets/stack_text_field.dart'; import '../../widgets/textfield_icon_button.dart'; import '../address_book_views/address_book_view.dart'; import '../coin_control/coin_control_view.dart'; +import '../open_crypto_pay/open_crypto_pay_view.dart'; import 'confirm_transaction_view.dart'; import 'sub_widgets/building_transaction_dialog.dart'; import 'sub_widgets/dual_balance_selection_sheet.dart'; @@ -305,15 +307,32 @@ class _SendViewState extends ConsumerState { if (paymentData != null && paymentData.coin?.uriScheme == coin.uriScheme) { _applyUri(paymentData); - } else { - _address = qrResult.rawContent!.split("\n").first.trim(); - sendToController.text = _address ?? ""; + return; + } - _setValidAddressProviders(_address); - setState(() { - _addressToggleFlag = sendToController.text.isNotEmpty; - }); + // Check for OpenCryptoPay QR code after standard payment URIs so a + // normal coin URI with a Lightning fallback still follows the usual flow. + if (LnurlUtils.isOpenCryptoPayUrl(qrResult.rawContent!)) { + if (mounted) { + await Navigator.of(context).pushNamed( + OpenCryptoPayView.routeName, + arguments: ( + qrUrl: qrResult.rawContent!, + walletId: walletId, + coin: coin, + ), + ); + } + return; } + + _address = qrResult.rawContent!.split("\n").first.trim(); + sendToController.text = _address ?? ""; + + _setValidAddressProviders(_address); + setState(() { + _addressToggleFlag = sendToController.text.isNotEmpty; + }); } on PlatformException catch (e, s) { // ref // .read( @@ -1079,6 +1098,7 @@ class _SendViewState extends ConsumerState { walletId: walletId, isPaynymTransaction: isPaynymSend, onSuccess: clearSendForm, + openCryptoPayCommit: _data?.openCryptoPayCommit, ), settings: const RouteSettings( name: ConfirmTransactionView.routeName, @@ -1314,6 +1334,7 @@ class _SendViewState extends ConsumerState { } sendToController.text = _data.contactLabel; _address = _data.address.trim(); + noteController.text = _data.note; _addressToggleFlag = true; WidgetsBinding.instance.addPostFrameCallback((timeStamp) { diff --git a/lib/pages/send_view/token_send_view.dart b/lib/pages/send_view/token_send_view.dart index 3d30fc5f6..49dc6e644 100644 --- a/lib/pages/send_view/token_send_view.dart +++ b/lib/pages/send_view/token_send_view.dart @@ -57,6 +57,7 @@ import '../../widgets/stack_text_field.dart'; import '../../widgets/textfield_icon_button.dart'; import '../address_book_views/address_book_view.dart'; import '../token_view/token_view.dart'; +import '../wallet_view/wallet_view.dart'; import 'confirm_transaction_view.dart'; import 'sub_widgets/building_transaction_dialog.dart'; import 'sub_widgets/transaction_fee_selection_sheet.dart'; @@ -522,7 +523,10 @@ class _TokenSendViewState extends ConsumerState { walletId: walletId, isTokenTx: true, onSuccess: clearSendForm, - routeOnSuccessName: TokenView.routeName, + routeOnSuccessName: _data?.openCryptoPayCommit == null + ? TokenView.routeName + : WalletView.routeName, + openCryptoPayCommit: _data?.openCryptoPayCommit, ), settings: const RouteSettings( name: ConfirmTransactionView.routeName, @@ -613,7 +617,9 @@ class _TokenSendViewState extends ConsumerState { } sendToController.text = _data.contactLabel; _address = _data.address.trim(); + noteController.text = _data.note; _addressToggleFlag = true; + _updatePreviewButtonState(_address, _amountToSend); } super.initState(); 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 efc02f628..7601a2101 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 @@ -24,6 +24,7 @@ import '../../../../models/isar/models/contact_entry.dart'; import '../../../../models/mwc_slatepack_models.dart'; import '../../../../models/paynym/paynym_account_lite.dart'; import '../../../../models/send_view_auto_fill_data.dart'; +import '../../../../pages/open_crypto_pay/open_crypto_pay_dialog.dart'; import '../../../../pages/send_view/confirm_transaction_view.dart'; import '../../../../pages/send_view/sub_widgets/building_transaction_dialog.dart'; import '../../../../pages/send_view/sub_widgets/epic_slatepack_dialog.dart'; @@ -34,6 +35,7 @@ import '../../../../providers/ui/fee_rate_type_state_provider.dart'; import '../../../../providers/ui/preview_tx_button_state_provider.dart'; import '../../../../providers/wallet/desktop_fee_providers.dart'; import '../../../../providers/wallet/public_private_balance_state_provider.dart'; +import '../../../../services/open_crypto_pay/lnurl_utils.dart'; import '../../../../services/spark_names_service.dart'; import '../../../../themes/stack_colors.dart'; import '../../../../utilities/address_utils.dart'; @@ -154,7 +156,7 @@ class _DesktopSendState extends ConsumerState { Logging.instance.w("Qr scanning cancelled"); } else { try { - _processQrCodeData(qrResult); + await _processQrCodeData(qrResult); } catch (e, s) { Logging.instance.e( "Error processing QR code data", @@ -772,6 +774,7 @@ class _DesktopSendState extends ConsumerState { onSuccess: clearSendForm, isPaynymTransaction: isPaynymSend, routeOnSuccessName: DesktopHomeView.routeName, + openCryptoPayCommit: _data?.openCryptoPayCommit, ), ), ), @@ -911,7 +914,7 @@ class _DesktopSendState extends ConsumerState { // return null; // } - void _processQrCodeData(String qrCodeData) { + Future _processQrCodeData(String qrCodeData) async { try { final paymentData = AddressUtils.parsePaymentUri( qrCodeData, @@ -921,6 +924,14 @@ class _DesktopSendState extends ConsumerState { if (paymentData != null && paymentData.coin?.uriScheme == coin.uriScheme) { _applyUri(paymentData); + } else if (LnurlUtils.isOpenCryptoPayUrl(qrCodeData)) { + if (!mounted) return; + await showOpenCryptoPayPaymentDesktopDialog( + context: context, + qrUrl: qrCodeData, + walletId: walletId, + coin: coin, + ); } else { _address = qrCodeData.split("\n").first.trim(); sendToController.text = _address ?? ""; @@ -1236,6 +1247,7 @@ class _DesktopSendState extends ConsumerState { } sendToController.text = _data.contactLabel; _address = _data.address; + _note = _data.note; _addressToggleFlag = true; } diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart index f01cdd246..d4e405b10 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart @@ -18,12 +18,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../models/isar/models/contact_entry.dart'; import '../../../../models/paynym/paynym_account_lite.dart'; import '../../../../models/send_view_auto_fill_data.dart'; +import '../../../../pages/open_crypto_pay/open_crypto_pay_dialog.dart'; import '../../../../pages/send_view/confirm_transaction_view.dart'; import '../../../../pages/send_view/sub_widgets/building_transaction_dialog.dart'; import '../../../../providers/providers.dart'; import '../../../../providers/ui/fee_rate_type_state_provider.dart'; import '../../../../providers/ui/preview_tx_button_state_provider.dart'; import '../../../../providers/wallet/desktop_fee_providers.dart'; +import '../../../../services/open_crypto_pay/lnurl_utils.dart'; import '../../../../themes/stack_colors.dart'; import '../../../../utilities/address_utils.dart'; import '../../../../utilities/amount/amount.dart'; @@ -269,6 +271,7 @@ class _DesktopTokenSendState extends ConsumerState { onSuccess: clearSendForm, isTokenTx: true, routeOnSuccessName: DesktopHomeView.routeName, + openCryptoPayCommit: _data?.openCryptoPayCommit, ), ), ), @@ -477,9 +480,16 @@ class _DesktopTokenSendState extends ConsumerState { setState(() { _addressToggleFlag = sendToController.text.isNotEmpty; }); - - // now check for non standard encoded basic address + } else if (LnurlUtils.isOpenCryptoPayUrl(qrResult)) { + if (!mounted) return; + await showOpenCryptoPayPaymentDesktopDialog( + context: context, + qrUrl: qrResult, + walletId: walletId, + coin: coin, + ); } else { + // now check for non standard encoded basic address _address = qrResult.split("\n").first.trim(); sendToController.text = _address ?? ""; @@ -616,6 +626,7 @@ class _DesktopTokenSendState extends ConsumerState { } sendToController.text = _data!.contactLabel; _address = _data!.address; + _note = _data!.note; _addressToggleFlag = true; } diff --git a/lib/route_generator.dart b/lib/route_generator.dart index 2a42a8af7..83a292d98 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -97,6 +97,7 @@ import 'pages/namecoin_names/manage_domain_view.dart'; import 'pages/namecoin_names/namecoin_names_home_view.dart'; import 'pages/namecoin_names/sub_widgets/name_details.dart'; import 'pages/notification_views/notifications_view.dart'; +import 'pages/open_crypto_pay/open_crypto_pay_view.dart'; import 'pages/ordinals/ordinal_details_view.dart'; import 'pages/ordinals/ordinals_filter_view.dart'; import 'pages/ordinals/ordinals_view.dart'; @@ -2117,6 +2118,20 @@ class RouteGenerator { } return _routeError("${settings.name} invalid args: ${args.toString()}"); + case OpenCryptoPayView.routeName: + if (args is ({String qrUrl, String walletId, CryptoCurrency coin})) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => OpenCryptoPayView( + qrUrl: args.qrUrl, + walletId: args.walletId, + coin: args.coin, + ), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + case SendView.routeName: if (args is Tuple2) { return getRoute( @@ -2166,6 +2181,23 @@ class RouteGenerator { ), settings: RouteSettings(name: settings.name), ); + } else if (args + is Tuple4< + String, + CryptoCurrency, + EthContract, + SendViewAutoFillData + >) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => TokenSendView( + walletId: args.item1, + coin: args.item2, + tokenContract: args.item3, + autoFillData: args.item4, + ), + settings: RouteSettings(name: settings.name), + ); } return _routeError("${settings.name} invalid args: ${args.toString()}"); diff --git a/lib/services/open_crypto_pay/erc20_token_lookup.dart b/lib/services/open_crypto_pay/erc20_token_lookup.dart new file mode 100644 index 000000000..105a6a8cb --- /dev/null +++ b/lib/services/open_crypto_pay/erc20_token_lookup.dart @@ -0,0 +1,29 @@ +import '../../db/isar/main_db.dart'; +import '../../models/isar/models/ethereum/eth_contract.dart'; + +class OpenCryptoPayErc20TokenLookup { + const OpenCryptoPayErc20TokenLookup._(); + + static List enabledTokens( + MainDB mainDB, + Iterable addresses, + ) { + return addresses + .map(mainDB.getEthContractSync) + .whereType() + .where((e) => e.type == EthContractType.erc20) + .toList(); + } + + static EthContract? enabledToken( + MainDB mainDB, + Iterable addresses, + String contractAddress, + ) { + final normalized = contractAddress.toLowerCase(); + for (final contract in enabledTokens(mainDB, addresses)) { + if (contract.address.toLowerCase() == normalized) return contract; + } + return null; + } +} diff --git a/lib/services/open_crypto_pay/evm_uri.dart b/lib/services/open_crypto_pay/evm_uri.dart new file mode 100644 index 000000000..cc68dd0c4 --- /dev/null +++ b/lib/services/open_crypto_pay/evm_uri.dart @@ -0,0 +1,100 @@ +import 'package:decimal/decimal.dart'; + +/// Minimal EIP-681 parser for Open CryptoPay EVM transaction details. +class OpenCryptoPayEvmUri { + final String scheme; + final String targetAddress; + final int? chainId; + final String? functionName; + final String? recipientAddress; + final BigInt? amountRaw; + + const OpenCryptoPayEvmUri({ + required this.scheme, + required this.targetAddress, + required this.chainId, + required this.functionName, + required this.recipientAddress, + required this.amountRaw, + }); + + bool get isTokenTransfer => + functionName == 'transfer' && + recipientAddress != null && + amountRaw != null; + + bool get isNativeTransfer => functionName == null && amountRaw != null; + + Decimal amount({required int fractionDigits}) => + Decimal.fromBigInt(amountRaw!).shift(-fractionDigits); + + static OpenCryptoPayEvmUri? tryParse(String uri) { + final parsed = Uri.tryParse(uri); + if (parsed == null || parsed.scheme != 'ethereum') return null; + + final pathParts = parsed.path.split('/'); + if (pathParts.isEmpty || pathParts.first.isEmpty) return null; + + final targetParts = pathParts.first.split('@'); + final targetAddress = targetParts.first; + if (!_isHexAddress(targetAddress)) return null; + + if (targetParts.length > 2) return null; + final int? chainId; + if (targetParts.length > 1) { + chainId = int.tryParse(targetParts[1]); + if (chainId == null) return null; + } else { + chainId = null; + } + final functionName = pathParts.length > 1 && pathParts[1].isNotEmpty + ? pathParts[1] + : null; + + final recipientAddress = parsed.queryParameters['address']; + final amountRaw = functionName == 'transfer' + ? _parseRawInteger(parsed.queryParameters['uint256']) + : _parseRawInteger(parsed.queryParameters['value']); + + return OpenCryptoPayEvmUri( + scheme: parsed.scheme, + targetAddress: targetAddress, + chainId: chainId, + functionName: functionName, + recipientAddress: + recipientAddress != null && _isHexAddress(recipientAddress) + ? recipientAddress + : null, + amountRaw: amountRaw, + ); + } + + static bool _isHexAddress(String value) => + RegExp(r'^0x[0-9a-fA-F]{40}$').hasMatch(value); + + static BigInt? _parseRawInteger(String? value) { + if (value == null) return null; + if (RegExp(r'^[0-9]+$').hasMatch(value)) return BigInt.tryParse(value); + + final match = RegExp( + r'^([0-9]+)(?:\.([0-9]+))?[eE]([+-]?[0-9]+)$', + ).firstMatch(value); + if (match == null) return null; + + final whole = match.group(1)!; + final fraction = match.group(2) ?? ''; + final exponent = int.tryParse(match.group(3)!); + if (exponent == null) return null; + + final digits = whole + fraction; + final scale = exponent - fraction.length; + if (scale >= 0) { + return BigInt.parse(digits) * BigInt.from(10).pow(scale); + } + + final divisor = BigInt.from(10).pow(-scale); + final parsed = BigInt.parse(digits); + if (parsed % divisor != BigInt.zero) return null; + return parsed ~/ divisor; + } +} diff --git a/lib/services/open_crypto_pay/lnurl_utils.dart b/lib/services/open_crypto_pay/lnurl_utils.dart new file mode 100644 index 000000000..eb0c4f177 --- /dev/null +++ b/lib/services/open_crypto_pay/lnurl_utils.dart @@ -0,0 +1,61 @@ +import 'dart:convert'; + +import 'package:bech32/bech32.dart'; + +/// LNURL (LUD-01) helpers scoped to Open CryptoPay QR handling. +/// +/// Stack does not support Lightning in general; this lives under +/// `services/open_crypto_pay/` because OCP is currently the sole consumer. +/// If broader LNURL support is ever added, promote this to `utilities/`. +class LnurlUtils { + /// Decodes a bech32-encoded LNURL string back to a URL. + static String decodeLnurl(String lnurl) { + final decoded = const Bech32Codec().decode(lnurl, lnurl.length); + return utf8.decode(_fromBase32(decoded.data)); + } + + /// Returns true if [url] is an Open CryptoPay QR payload. + static bool isOpenCryptoPayUrl(String url) { + return extractLnurl(url)?.toUpperCase().startsWith('LNURL') ?? false; + } + + /// Returns the encoded LNURL payload, if any. + static String? extractLnurl(String url) { + final trimmed = url.trim(); + if (trimmed.toUpperCase().startsWith('LNURL')) return trimmed; + + const lightningScheme = 'lightning:'; + if (trimmed.toLowerCase().startsWith(lightningScheme)) { + final payload = trimmed.substring(lightningScheme.length); + if (payload.toUpperCase().startsWith('LNURL')) return payload; + } + + try { + return Uri.parse(trimmed).queryParameters['lightning']; + } catch (_) { + return null; + } + } + + /// Regroups 5-bit bech32 data into 8-bit bytes. + static List _fromBase32(List data) { + int acc = 0; + int bits = 0; + final result = []; + for (final value in data) { + if (value < 0 || (value >> 5) != 0) { + throw const FormatException('Invalid bech32 data'); + } + acc = (acc << 5) | value; + bits += 5; + while (bits >= 8) { + bits -= 8; + result.add((acc >> bits) & 0xff); + } + } + if (bits >= 5 || ((acc << (8 - bits)) & 0xff) != 0) { + throw const FormatException('Invalid bech32 padding'); + } + return result; + } +} diff --git a/lib/services/open_crypto_pay/method_support.dart b/lib/services/open_crypto_pay/method_support.dart new file mode 100644 index 000000000..f15faa3b2 --- /dev/null +++ b/lib/services/open_crypto_pay/method_support.dart @@ -0,0 +1,61 @@ +import '../../wallets/crypto_currency/crypto_currency.dart'; +import 'models.dart'; + +/// Centralizes which Open CryptoPay methods Stack can complete safely with the +/// existing send flow. +class OpenCryptoPayMethodSupport { + const OpenCryptoPayMethodSupport._(); + + static const _methodsByCoinType = { + Bitcoin: 'Bitcoin', + Solana: 'Solana', + Cardano: 'Cardano', + Firo: 'Firo', + }; + + static OpenCryptoPaySubmissionFlow? submissionFlowFor(String method) { + switch (method) { + case 'Solana': + case 'Cardano': + return OpenCryptoPaySubmissionFlow.txIdAfterLocalBroadcast; + case 'Monero': + // Monero can be revisited on the txid flow in a follow-up; this PR + // keeps support scoped to methods already validated here. + return null; + case 'Ethereum': + case 'Bitcoin': + case 'Firo': + // Firo starts here for transparent/provider-broadcast payments; Spark + // or oversized raw transactions fall back to txid at confirmation. + return OpenCryptoPaySubmissionFlow.rawHexToProvider; + default: + // Known unsupported methods include Lightning, BinancePay, and ICP. + return null; + } + } + + static bool isSupportedWalletOption({ + required CryptoCurrency coin, + required OpenCryptoPayTransferMethod method, + required OpenCryptoPayAsset asset, + Iterable enabledErc20Symbols = const [], + }) { + final ticker = coin.ticker.toUpperCase(); + final assetTicker = asset.asset.toUpperCase(); + final methodName = _methodForCoin(coin); + + if (methodName == null || method.method != methodName) return false; + + if (coin is Ethereum) { + if (assetTicker == ticker) return true; + return enabledErc20Symbols + .map((e) => e.toUpperCase()) + .contains(assetTicker); + } + + return assetTicker == ticker; + } + + static String? _methodForCoin(CryptoCurrency coin) => + coin is Ethereum ? 'Ethereum' : _methodsByCoinType[coin.runtimeType]; +} diff --git a/lib/services/open_crypto_pay/models.dart b/lib/services/open_crypto_pay/models.dart new file mode 100644 index 000000000..1e4130050 --- /dev/null +++ b/lib/services/open_crypto_pay/models.dart @@ -0,0 +1,281 @@ +import 'package:decimal/decimal.dart'; + +/// Data models for the Open CryptoPay standard. +/// +/// See https://github.com/openCryptoPay/landingPage + +enum OpenCryptoPaySubmissionFlow { + /// The wallet broadcasts locally, then sends the resulting txid to `/tx/`. + txIdAfterLocalBroadcast, + + /// The provider broadcasts after receiving raw signed transaction hex. + rawHexToProvider, +} + +class OpenCryptoPayRecipient { + final String? name; + final String? street; + final String? houseNumber; + final String? zip; + final String? city; + final String? country; + + OpenCryptoPayRecipient({ + this.name, + this.street, + this.houseNumber, + this.zip, + this.city, + this.country, + }); + + factory OpenCryptoPayRecipient.fromJson(Map json) { + final address = json['address'] as Map?; + return OpenCryptoPayRecipient( + name: json['name'] as String?, + street: address?['street'] as String?, + houseNumber: address?['houseNumber'] as String?, + zip: address?['zip'] as String?, + city: address?['city'] as String?, + country: address?['country'] as String?, + ); + } + + String get formattedAddress { + final parts = []; + if (street != null) { + parts.add(houseNumber != null ? '$street $houseNumber' : street!); + } + if (zip != null || city != null) { + parts.add([zip, city].whereType().join(' ')); + } + if (country != null) parts.add(country!); + return parts.join(', '); + } +} + +class OpenCryptoPayAsset { + final String asset; + final String amount; + + OpenCryptoPayAsset({required this.asset, required this.amount}); + + factory OpenCryptoPayAsset.fromJson(Map json) { + return OpenCryptoPayAsset( + asset: json['asset'] as String, + amount: json['amount'].toString(), + ); + } +} + +class OpenCryptoPayTransferMethod { + final String method; + final List assets; + final bool available; + final Decimal minFee; + + OpenCryptoPayTransferMethod({ + required this.method, + required this.assets, + required this.available, + required this.minFee, + }); + + factory OpenCryptoPayTransferMethod.fromJson(Map json) { + return OpenCryptoPayTransferMethod( + method: json['method'] as String, + minFee: + Decimal.tryParse(json['minFee']?.toString() ?? '0') ?? Decimal.zero, + assets: (json['assets'] as List) + .map((e) => OpenCryptoPayAsset.fromJson(e as Map)) + .toList(), + available: json['available'] as bool, + ); + } +} + +class OpenCryptoPayQuote { + final String id; + final String paymentId; + final DateTime expiration; + + OpenCryptoPayQuote({ + required this.id, + required this.paymentId, + required this.expiration, + }); + + factory OpenCryptoPayQuote.fromJson( + Map json, { + String? fallbackPaymentId, + }) { + final paymentId = json['payment'] as String? ?? fallbackPaymentId; + if (paymentId == null || paymentId.isEmpty) { + throw Exception('OpenCryptoPay: quote payment id is missing'); + } + + return OpenCryptoPayQuote( + id: json['id'] as String, + paymentId: paymentId, + expiration: DateTime.parse(json['expiration'] as String), + ); + } + + bool get isExpired => expiration.isBefore(DateTime.now()); +} + +class OpenCryptoPayRequestedAmount { + final String asset; + final num amount; + + OpenCryptoPayRequestedAmount({required this.asset, required this.amount}); + + factory OpenCryptoPayRequestedAmount.fromJson(Map json) { + return OpenCryptoPayRequestedAmount( + asset: json['asset'] as String, + amount: json['amount'] as num, + ); + } +} + +class OpenCryptoPayPaymentDetails { + final String? standard; + final List possibleStandards; + final String? displayName; + final String callback; + final OpenCryptoPayRecipient? recipient; + final OpenCryptoPayQuote? quote; + final OpenCryptoPayRequestedAmount? requestedAmount; + final List transferAmounts; + + OpenCryptoPayPaymentDetails({ + this.standard, + required this.possibleStandards, + this.displayName, + required this.callback, + this.recipient, + this.quote, + this.requestedAmount, + required this.transferAmounts, + }); + + factory OpenCryptoPayPaymentDetails.fromJson(Map json) { + final callback = json['callback'] as String? ?? ''; + return OpenCryptoPayPaymentDetails( + standard: json['standard'] as String?, + possibleStandards: + (json['possibleStandards'] as List?) + ?.map((e) => e.toString()) + .toList() ?? + const [], + displayName: json['displayName'] as String?, + callback: callback, + recipient: json['recipient'] == null + ? null + : OpenCryptoPayRecipient.fromJson( + json['recipient'] as Map, + ), + quote: json['quote'] == null + ? null + : OpenCryptoPayQuote.fromJson( + json['quote'] as Map, + fallbackPaymentId: _paymentIdFromCallback(callback), + ), + requestedAmount: json['requestedAmount'] == null + ? null + : OpenCryptoPayRequestedAmount.fromJson( + json['requestedAmount'] as Map, + ), + transferAmounts: + (json['transferAmounts'] as List?) + ?.map( + (e) => OpenCryptoPayTransferMethod.fromJson( + e as Map, + ), + ) + .toList() ?? + [], + ); + } + + /// Methods that are available and have at least one asset. + List get availableMethods => + transferAmounts.where((m) => m.available && m.assets.isNotEmpty).toList(); + + bool get supportsOpenCryptoPay => + standard == 'OpenCryptoPay' || + possibleStandards.contains('OpenCryptoPay') || + (callback.isNotEmpty && quote != null && transferAmounts.isNotEmpty); + + static String? _paymentIdFromCallback(String callback) { + final segments = Uri.tryParse(callback)?.pathSegments; + final cbIndex = segments?.lastIndexOf('cb') ?? -1; + if (segments == null || cbIndex == -1 || cbIndex + 1 >= segments.length) { + return null; + } + return segments[cbIndex + 1]; + } +} + +class OpenCryptoPayTransactionDetails { + final String? blockchain; + final String? uri; + final String? hint; + final DateTime? expiryDate; + + OpenCryptoPayTransactionDetails({ + this.blockchain, + this.uri, + this.hint, + this.expiryDate, + }); + + factory OpenCryptoPayTransactionDetails.fromJson(Map json) { + return OpenCryptoPayTransactionDetails( + blockchain: json['blockchain'] as String?, + uri: json['uri'] as String?, + hint: json['hint'] as String?, + expiryDate: json['expiryDate'] == null + ? null + : DateTime.parse(json['expiryDate'] as String), + ); + } +} + +/// Context required to notify the provider via the `/tx/{paymentId}` endpoint. +class OpenCryptoPayCommit { + final String callbackUrl; + final String quoteId; + final String paymentId; + final String method; + final String asset; + final DateTime expiresAt; + final OpenCryptoPaySubmissionFlow submissionFlow; + final Decimal minFee; + final String recipientAddress; + final Decimal amount; + final String? tokenContractAddress; + + const OpenCryptoPayCommit({ + required this.callbackUrl, + required this.quoteId, + required this.paymentId, + required this.method, + required this.asset, + required this.expiresAt, + required this.submissionFlow, + required this.minFee, + required this.recipientAddress, + required this.amount, + this.tokenContractAddress, + }); + + bool get isExpired => expiresAt.isBefore(DateTime.now()); + + bool get canCommitRawHex => + submissionFlow == OpenCryptoPaySubmissionFlow.rawHexToProvider; + + bool get canCommitTxId => + submissionFlow == OpenCryptoPaySubmissionFlow.txIdAfterLocalBroadcast || + method == 'Firo'; +} diff --git a/lib/services/open_crypto_pay/open_crypto_pay_api.dart b/lib/services/open_crypto_pay/open_crypto_pay_api.dart new file mode 100644 index 000000000..82e10470b --- /dev/null +++ b/lib/services/open_crypto_pay/open_crypto_pay_api.dart @@ -0,0 +1,213 @@ +import 'dart:convert'; +import 'dart:io'; + +import '../../app_config.dart'; +import '../../networking/http.dart'; +import '../../utilities/logger.dart'; +import '../../utilities/prefs.dart'; +import '../tor_service.dart'; +import 'lnurl_utils.dart'; +import 'models.dart'; + +/// Client for the Open CryptoPay standard. +/// +/// See https://github.com/openCryptoPay/landingPage +class OpenCryptoPayApi { + OpenCryptoPayApi._(); + + static final OpenCryptoPayApi instance = OpenCryptoPayApi._(); + + final HTTP _client = const HTTP(); + + static const Duration _httpTimeout = Duration(seconds: 15); + static const Duration _commitTimeout = Duration(seconds: 30); + + ({InternetAddress host, int port})? get _proxyInfo => + AppConfig.hasFeature(AppFeature.tor) && Prefs.instance.useTor + ? TorService.sharedInstance.getProxyInfo() + : null; + + /// Throws if [uri] is not an absolute https URL. LUD-01 mandates HTTPS; + /// rejecting plain http also closes off MITM and SSRF-into-loopback risks + /// from a malicious QR. + void _requireHttps(Uri uri, String label) { + if (uri.scheme != 'https' || !uri.hasAuthority) { + throw Exception('OpenCryptoPay: $label must be an https URL'); + } + } + + /// Fetches the payment details (available methods, quote, recipient, etc) + /// for the payment encoded in [qrUrl]. + Future getPaymentDetails(String qrUrl) async { + final lnurl = LnurlUtils.extractLnurl(qrUrl); + if (lnurl == null) { + throw Exception('No LNURL payload found'); + } + + final apiUrl = Uri.parse(LnurlUtils.decodeLnurl(lnurl)); + _requireHttps(apiUrl, 'decoded LNURL'); + final uri = apiUrl.replace( + queryParameters: {...apiUrl.queryParameters, 'timeout': '10'}, + ); + + Logging.instance.d('OpenCryptoPay: GET $uri'); + final response = await _get(uri); + + if (response.code == 404) { + String message = 'No pending payment found'; + try { + final json = jsonDecode(response.body) as Map; + message = json['message'] as String? ?? message; + } catch (_) {} + throw OpenCryptoPayNoPendingPaymentException(message); + } + if (response.code != 200) { + throw Exception('OpenCryptoPay ${response.code}: ${response.body}'); + } + + final json = jsonDecode(response.body) as Map; + final details = OpenCryptoPayPaymentDetails.fromJson(json); + if (!details.supportsOpenCryptoPay) { + throw Exception('OpenCryptoPay: endpoint did not return OpenCryptoPay'); + } + + // Pin all subsequent calls (callback fetch + commit) to the same host as + // the LNURL we already trusted. Otherwise a malicious provider response + // could redirect the txid + raw hex to an attacker-controlled host. + final callback = Uri.tryParse(details.callback); + if (callback == null) { + throw Exception('OpenCryptoPay: invalid callback URL'); + } + _requireHttps(callback, 'callback'); + if (callback.host != apiUrl.host) { + throw Exception( + 'OpenCryptoPay: callback host ${callback.host} does not match ' + 'LNURL host ${apiUrl.host}', + ); + } + + return details; + } + + /// Fetches the transaction details (payment address URI) for the chosen + /// [method] and [asset]. + Future getTransactionDetails({ + required String callbackUrl, + required String quoteId, + required String method, + required String asset, + }) async { + final base = Uri.parse(callbackUrl); + _requireHttps(base, 'callback'); + final uri = base.replace( + queryParameters: { + ...base.queryParameters, + 'quote': quoteId, + 'method': method, + 'asset': asset, + }, + ); + + Logging.instance.d('OpenCryptoPay: GET $uri'); + final response = await _get(uri); + + if (response.code != 200) { + throw Exception('OpenCryptoPay ${response.code}: ${response.body}'); + } + + return OpenCryptoPayTransactionDetails.fromJson( + jsonDecode(response.body) as Map, + ); + } + + /// Notifies the provider of a locally broadcast transaction so the merchant + /// side can settle the payment. + Future commitTxId({ + required OpenCryptoPayCommit commit, + required String txId, + }) async { + if (!commit.canCommitTxId) { + throw UnsupportedError( + 'OpenCryptoPay method ${commit.method} cannot be committed with txid', + ); + } + + await _commit(commit: commit, queryParameters: {'tx': txId}); + } + + /// Sends raw signed transaction hex to the provider for methods where the + /// provider is responsible for broadcasting. + Future commitRawHex({ + required OpenCryptoPayCommit commit, + required String hex, + }) async { + if (!commit.canCommitRawHex) { + throw UnsupportedError( + 'OpenCryptoPay method ${commit.method} cannot be committed with hex', + ); + } + + await _commit(commit: commit, queryParameters: {'hex': hex}); + } + + Future _commit({ + required OpenCryptoPayCommit commit, + required Map queryParameters, + }) async { + final base = _commitEndpoint(commit.callbackUrl, commit.paymentId); + _requireHttps(base, 'commit endpoint'); + final uri = base.replace( + queryParameters: { + ...base.queryParameters, + 'quote': commit.quoteId, + 'method': commit.method, + 'asset': commit.asset, + ...queryParameters, + }, + ); + + Logging.instance.d('OpenCryptoPay: GET ${_redactedUri(uri)}'); + final response = await _get(uri, timeout: _commitTimeout); + if (response.code != 200) { + throw Exception( + 'OpenCryptoPay commit ${response.code}: ${response.body}', + ); + } + } + + Uri _commitEndpoint(String callbackUrl, String paymentId) { + final callback = Uri.parse(callbackUrl); + if (paymentId.isEmpty) { + throw Exception('OpenCryptoPay: quote payment id is missing'); + } + final segments = callback.pathSegments.toList(); + final cbIndex = segments.lastIndexOf('cb'); + if (cbIndex == -1) { + throw Exception('OpenCryptoPay: callback URL does not contain /cb/'); + } + return callback.replace( + pathSegments: [...segments.take(cbIndex), 'tx', paymentId], + ); + } + + Uri _redactedUri(Uri uri) { + if (!uri.queryParameters.containsKey('hex')) return uri; + return uri.replace( + queryParameters: {...uri.queryParameters, 'hex': ''}, + ); + } + + Future _get(Uri uri, {Duration timeout = _httpTimeout}) { + return _client + .get(url: uri, proxyInfo: _proxyInfo, connectionTimeout: timeout) + .timeout(timeout); + } +} + +class OpenCryptoPayNoPendingPaymentException implements Exception { + final String message; + OpenCryptoPayNoPendingPaymentException(this.message); + + @override + String toString() => message; +} diff --git a/lib/services/open_crypto_pay/settlement.dart b/lib/services/open_crypto_pay/settlement.dart new file mode 100644 index 000000000..5abdfcd97 --- /dev/null +++ b/lib/services/open_crypto_pay/settlement.dart @@ -0,0 +1,312 @@ +import 'package:decimal/decimal.dart'; +import 'package:isar_community/isar.dart'; + +import '../../db/isar/main_db.dart'; +import '../../models/input.dart'; +import '../../utilities/amount/amount.dart'; +import '../../utilities/logger.dart'; +import '../../wallets/crypto_currency/crypto_currency.dart'; +import '../../wallets/isar/models/spark_coin.dart'; +import '../../wallets/models/tx_data.dart'; +import '../../wallets/wallet/impl/ethereum_wallet.dart'; +import '../../wallets/wallet/impl/sub_wallets/eth_token_wallet.dart'; +import '../../wallets/wallet/wallet.dart'; +import 'models.dart'; +import 'open_crypto_pay_api.dart'; + +class OpenCryptoPaySettlement { + OpenCryptoPaySettlement({ + required this.wallet, + required this.txData, + required this.commit, + required this.mainDB, + required this.isTokenTx, + this.tokenWallet, + }); + + // OCP raw-hex commits are GET query params; keep Firo near common header caps. + static const int maxRawHexQueryLength = 8000; + + final Wallet wallet; + final TxData txData; + final OpenCryptoPayCommit commit; + final MainDB mainDB; + final bool isTokenTx; + final EthTokenWallet? tokenWallet; + + bool get shouldCommitTxId => shouldCommitTxIdFor( + method: commit.method, + submissionFlow: commit.submissionFlow, + cryptoCurrency: wallet.cryptoCurrency, + hasSparkInputs: txData.usedSparkCoins?.isNotEmpty == true, + rawHexLength: txData.raw?.length ?? 0, + ); + + static bool shouldCommitTxIdFor({ + required String method, + required OpenCryptoPaySubmissionFlow submissionFlow, + required CryptoCurrency cryptoCurrency, + required bool hasSparkInputs, + required int rawHexLength, + }) { + if (submissionFlow == OpenCryptoPaySubmissionFlow.txIdAfterLocalBroadcast) { + return true; + } + return method == 'Firo' && + cryptoCurrency is Firo && + (hasSparkInputs || rawHexLength > maxRawHexQueryLength); + } + + bool get shouldSubmitRawHex => !shouldCommitTxId && commit.canCommitRawHex; + + String? validate() { + final minFeeError = _validateMinFee(); + if (minFeeError != null) return minFeeError; + + final transactionError = _validateTransaction(); + if (transactionError != null) return transactionError; + + final tokenError = _validateToken(); + if (tokenError != null) return tokenError; + + switch (commit.submissionFlow) { + case OpenCryptoPaySubmissionFlow.txIdAfterLocalBroadcast: + return null; + case OpenCryptoPaySubmissionFlow.rawHexToProvider: + if (shouldCommitTxId) return null; + if (wallet.cryptoCurrency is! Firo && + wallet.cryptoCurrency is! Ethereum && + wallet.cryptoCurrency is! Bitcoin) { + return "This Open CryptoPay method is not supported yet"; + } + if (wallet.cryptoCurrency is Ethereum) { + if (txData.web3dartTransaction == null || txData.chainId == null) { + return "Could not build signed Ethereum transaction"; + } + } else if (txData.raw == null || txData.raw!.isEmpty) { + return "Could not build signed transaction"; + } + return null; + } + } + + Future submitRawHex(Wallet submitWallet) async { + final signedTx = await _prepareRawHexTx(submitWallet, txData); + final raw = signedTx.raw; + if (raw == null || raw.isEmpty) { + throw Exception("Could not build signed transaction"); + } + + final txid = signedTx.tempTx?.txid ?? signedTx.txid ?? signedTx.txHash; + if (txid == null || txid.isEmpty) { + throw Exception("Could not determine signed transaction ID"); + } + if (commit.isExpired) { + throw Exception("Open CryptoPay quote expired. Please scan again."); + } + + await OpenCryptoPayApi.instance.commitRawHex(commit: commit, hex: raw); + + final updatedInputs = signedTx.usedUTXOs?.map((e) { + if (e is StandardInput) { + return StandardInput( + e.utxo.copyWith(used: true), + derivePathType: e.derivePathType, + ); + } + return e; + }).toList(); + + final updatedTxData = signedTx.copyWith( + usedUTXOs: updatedInputs, + txHash: txid, + txid: txid, + ); + + final updatedUtxos = updatedInputs + ?.whereType() + .map((e) => e.utxo) + .toList(); + if (updatedUtxos != null && updatedUtxos.isNotEmpty) { + await mainDB.putUTXOs(updatedUtxos); + } + + if (updatedTxData.usedSparkCoins != null && + updatedTxData.usedSparkCoins!.isNotEmpty) { + await mainDB.isar.writeTxn(() async { + await mainDB.isar.sparkCoins.putAll(updatedTxData.usedSparkCoins!); + }); + } + + return await submitWallet.updateSentCachedTxData(txData: updatedTxData); + } + + Future commitTxId(TxData txData) async { + try { + await OpenCryptoPayApi.instance.commitTxId( + commit: commit, + txId: txData.txid!, + ); + } catch (e, s) { + Logging.instance.e( + "OpenCryptoPay commit failed after local broadcast", + error: e, + stackTrace: s, + ); + throw Exception( + "Open CryptoPay commit failed after broadcasting " + "${txData.txid}: $e", + ); + } + } + + String? _validateTransaction() { + return validateTransaction( + cryptoCurrency: wallet.cryptoCurrency, + recipients: _recipients(txData), + recipientAddress: commit.recipientAddress, + amount: commit.amount, + ); + } + + static String? validateTransaction({ + required CryptoCurrency cryptoCurrency, + required List<({String address, Amount amount})> recipients, + required String recipientAddress, + required Decimal amount, + }) { + if (recipients.length != 1) { + return "Open CryptoPay requires exactly one recipient"; + } + + final actual = recipients.single; + if (_normalizeAddress(cryptoCurrency, actual.address) != + _normalizeAddress(cryptoCurrency, recipientAddress)) { + return "Open CryptoPay recipient changed. Please scan again."; + } + + if (actual.amount.decimal != amount) { + return "Open CryptoPay amount changed. Please scan again."; + } + + return null; + } + + String? _validateToken() { + final tokenContractAddress = commit.tokenContractAddress; + if (tokenContractAddress == null) return null; + + if (!isTokenTx || commit.method != 'Ethereum') { + return "Open CryptoPay token payment is not supported here"; + } + + final wallet = tokenWallet; + if (wallet == null) { + return "Could not verify Open CryptoPay token wallet"; + } + + if (wallet.tokenContract.address.toLowerCase() != + tokenContractAddress.toLowerCase()) { + return "Open CryptoPay token contract changed. Please scan again."; + } + + if (wallet.tokenContract.symbol.toUpperCase() != + commit.asset.toUpperCase()) { + return "Open CryptoPay token asset changed. Please scan again."; + } + + return null; + } + + String? _validateMinFee() => validateMinFee( + cryptoCurrency: wallet.cryptoCurrency, + minFee: commit.minFee, + gasPrice: txData.web3dartTransaction?.maxFeePerGas?.getInWei, + fee: txData.fee, + vSize: txData.vSize, + ); + + static String? validateMinFee({ + required CryptoCurrency cryptoCurrency, + required Decimal minFee, + BigInt? gasPrice, + Amount? fee, + int? vSize, + }) { + if (minFee <= Decimal.zero) return null; + + if (cryptoCurrency is Ethereum) { + if (gasPrice == null) { + return "Could not verify Open CryptoPay minimum gas price"; + } + if (gasPrice < _ceilDecimalToBigInt(minFee)) { + return "Open CryptoPay requires at least " + "$minFee wei gas price"; + } + return null; + } + + if (cryptoCurrency is Bitcoin || cryptoCurrency is Firo) { + if (fee == null || vSize == null || vSize <= 0) { + return "Could not verify Open CryptoPay minimum fee"; + } + final minTotalFee = _ceilDecimalToBigInt(minFee * Decimal.fromInt(vSize)); + if (fee.raw < minTotalFee) { + return "Open CryptoPay requires at least " + "$minFee sat/vB fee"; + } + } + + return null; + } + + static BigInt _ceilDecimalToBigInt(Decimal value) { + return value.ceil().toBigInt(); + } + + List<({String address, Amount amount})> _recipients(TxData txData) { + final recipients = <({String address, Amount amount})>[]; + final standardRecipients = txData.recipients; + if (standardRecipients != null) { + for (final recipient in standardRecipients) { + if (!recipient.isChange) { + recipients.add(( + address: recipient.address, + amount: recipient.amount, + )); + } + } + } + final sparkRecipients = txData.sparkRecipients; + if (sparkRecipients != null) { + for (final recipient in sparkRecipients) { + if (!recipient.isChange) { + recipients.add(( + address: recipient.address, + amount: recipient.amount, + )); + } + } + } + return recipients; + } + + static String _normalizeAddress( + CryptoCurrency cryptoCurrency, + String address, + ) { + if (cryptoCurrency is Ethereum) return address.toLowerCase(); + return address; + } + + Future _prepareRawHexTx(Wallet wallet, TxData txData) async { + if (wallet is EthTokenWallet) { + return await wallet.signSendWithoutBroadcast(txData: txData); + } + if (wallet is EthereumWallet) { + return await wallet.signSendWithoutBroadcast(txData: txData); + } + + return txData; + } +} diff --git a/lib/wallets/wallet/impl/ethereum_wallet.dart b/lib/wallets/wallet/impl/ethereum_wallet.dart index 829110651..1bd924e07 100644 --- a/lib/wallets/wallet/impl/ethereum_wallet.dart +++ b/lib/wallets/wallet/impl/ethereum_wallet.dart @@ -218,7 +218,9 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { final addressHex = (await getCurrentReceivingAddress())!.value; final address = eth_wallet.EthereumAddress.fromHex(addressHex); - final eth_wallet.EtherAmount ethBalance = await client.getBalance(address); + final eth_wallet.EtherAmount ethBalance = await client.getBalance( + address, + ); final balance = Balance( total: Amount( rawValue: ethBalance.getInWei, @@ -584,6 +586,29 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { } } + Future signSendWithoutBroadcast({ + required TxData txData, + TxData Function(TxData txData, String myAddress)? prepareTempTx, + }) async { + final client = getEthClient(); + if (_credentials == null) { + await _initCredentials(); + } + + final signedTx = await client.signTransaction( + _credentials!, + txData.web3dartTransaction!, + chainId: txData.chainId!.toInt(), + ); + final txid = web3.bytesToHex(web3.keccak256(signedTx), include0x: true); + final raw = web3.bytesToHex(signedTx, include0x: true); + + return (prepareTempTx ?? _prepareTempTx)( + txData.copyWith(raw: raw, txid: txid, txHash: txid), + (await getCurrentReceivingAddress())!.value, + ); + } + @override Future recover({required bool isRescan}) async { await refreshMutex.protect(() async { diff --git a/lib/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart b/lib/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart index 6aca5a008..24065d801 100644 --- a/lib/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart +++ b/lib/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart @@ -281,6 +281,13 @@ class EthTokenWallet extends Wallet { } } + Future signSendWithoutBroadcast({required TxData txData}) async { + return await ethWallet.signSendWithoutBroadcast( + txData: txData, + prepareTempTx: _prepareTempTx, + ); + } + @override Future estimateFeeFor(Amount amount, BigInt feeRate) async { return ethWallet.estimateEthFee( diff --git a/test/open_crypto_pay_evm_uri_test.dart b/test/open_crypto_pay_evm_uri_test.dart new file mode 100644 index 000000000..5558c0fd0 --- /dev/null +++ b/test/open_crypto_pay_evm_uri_test.dart @@ -0,0 +1,107 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/services/open_crypto_pay/evm_uri.dart'; + +void main() { + test("parses native Ethereum payment URI", () { + final result = OpenCryptoPayEvmUri.tryParse( + "ethereum:0x9C2242a0B71FD84661Fd4bC56b75c90Fac6d10FC@1" + "?value=660720000000000", + ); + + expect(result, isNotNull); + expect(result!.targetAddress, "0x9C2242a0B71FD84661Fd4bC56b75c90Fac6d10FC"); + expect(result.chainId, 1); + expect(result.functionName, isNull); + expect(result.amountRaw, BigInt.parse("660720000000000")); + expect(result.isNativeTransfer, true); + expect(result.isTokenTransfer, false); + }); + + test("parses EIP-681 scientific notation atomic amounts", () { + final result = OpenCryptoPayEvmUri.tryParse( + "ethereum:0x9C2242a0B71FD84661Fd4bC56b75c90Fac6d10FC@1" + "?value=6.6072e14", + ); + + expect(result, isNotNull); + expect(result!.amountRaw, BigInt.parse("660720000000000")); + }); + + test("parses ERC20 transfer URI", () { + final result = OpenCryptoPayEvmUri.tryParse( + "ethereum:0xdAC17F958D2ee523a2206206994597C13D831ec7@1/transfer" + "?address=0x9C2242a0B71FD84661Fd4bC56b75c90Fac6d10FC&uint256=1261570", + ); + + expect(result, isNotNull); + expect(result!.targetAddress, "0xdAC17F958D2ee523a2206206994597C13D831ec7"); + expect(result.chainId, 1); + expect(result.functionName, "transfer"); + expect( + result.recipientAddress, + "0x9C2242a0B71FD84661Fd4bC56b75c90Fac6d10FC", + ); + expect(result.amountRaw, BigInt.from(1261570)); + expect(result.isTokenTransfer, true); + }); + + test("parses non-mainnet chain id for caller validation", () { + final result = OpenCryptoPayEvmUri.tryParse( + "ethereum:0xdAC17F958D2ee523a2206206994597C13D831ec7@5/transfer" + "?address=0x9C2242a0B71FD84661Fd4bC56b75c90Fac6d10FC&uint256=1", + ); + + expect(result, isNotNull); + expect(result!.chainId, 5); + expect(result.isTokenTransfer, true); + }); + + test("rejects malformed chain id", () { + final result = OpenCryptoPayEvmUri.tryParse( + "ethereum:0xdAC17F958D2ee523a2206206994597C13D831ec7@abc/transfer" + "?address=0x9C2242a0B71FD84661Fd4bC56b75c90Fac6d10FC&uint256=1", + ); + + expect(result, isNull); + }); + + test("rejects malformed contract address", () { + final result = OpenCryptoPayEvmUri.tryParse( + "ethereum:not-a-contract@1/transfer" + "?address=0x9C2242a0B71FD84661Fd4bC56b75c90Fac6d10FC&uint256=1", + ); + + expect(result, isNull); + }); + + test("does not mark unsupported contract calls as ERC20 transfers", () { + final result = OpenCryptoPayEvmUri.tryParse( + "ethereum:0xdAC17F958D2ee523a2206206994597C13D831ec7@1/approve" + "?address=0x9C2242a0B71FD84661Fd4bC56b75c90Fac6d10FC&uint256=1", + ); + + expect(result, isNotNull); + expect(result!.functionName, "approve"); + expect(result.isTokenTransfer, false); + }); + + test("does not mark token transfer missing recipient as valid", () { + final result = OpenCryptoPayEvmUri.tryParse( + "ethereum:0xdAC17F958D2ee523a2206206994597C13D831ec7@1/transfer" + "?uint256=1", + ); + + expect(result, isNotNull); + expect(result!.isTokenTransfer, false); + }); + + test("does not mark token transfer missing amount as valid", () { + final result = OpenCryptoPayEvmUri.tryParse( + "ethereum:0xdAC17F958D2ee523a2206206994597C13D831ec7@1/transfer" + "?address=0x9C2242a0B71FD84661Fd4bC56b75c90Fac6d10FC", + ); + + expect(result, isNotNull); + expect(result!.isTokenTransfer, false); + }); +} diff --git a/test/open_crypto_pay_lnurl_utils_test.dart b/test/open_crypto_pay_lnurl_utils_test.dart new file mode 100644 index 000000000..ee0470ed8 --- /dev/null +++ b/test/open_crypto_pay_lnurl_utils_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/services/open_crypto_pay/lnurl_utils.dart'; + +void main() { + test("detects raw LNURL payloads", () { + expect(LnurlUtils.extractLnurl("LNURL1TEST"), "LNURL1TEST"); + expect(LnurlUtils.isOpenCryptoPayUrl("lnurl1test"), true); + }); + + test("detects lightning-scheme LNURL payloads", () { + expect(LnurlUtils.extractLnurl("lightning:LNURL1TEST"), "LNURL1TEST"); + expect(LnurlUtils.isOpenCryptoPayUrl("LIGHTNING:lnurl1test"), true); + }); + + test("detects LNURL in lightning query parameter", () { + const payload = "https://example.com/pay?lightning=LNURL1TEST"; + + expect(LnurlUtils.extractLnurl(payload), "LNURL1TEST"); + expect(LnurlUtils.isOpenCryptoPayUrl(payload), true); + }); + + test("ignores URLs without an LNURL payload", () { + expect(LnurlUtils.extractLnurl("https://example.com/pay"), isNull); + expect(LnurlUtils.isOpenCryptoPayUrl("https://example.com/pay"), false); + }); +} diff --git a/test/open_crypto_pay_models_test.dart b/test/open_crypto_pay_models_test.dart new file mode 100644 index 000000000..741e7bd63 --- /dev/null +++ b/test/open_crypto_pay_models_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/services/open_crypto_pay/models.dart'; + +void main() { + test("parses quote payment id used for commit endpoint", () { + final quote = OpenCryptoPayQuote.fromJson({ + "id": "quote-id", + "payment": "payment-id", + "expiration": "2026-04-28T12:00:00Z", + }); + + expect(quote.id, "quote-id"); + expect(quote.paymentId, "payment-id"); + }); + + test("rejects quotes without a payment id", () { + expect( + () => OpenCryptoPayQuote.fromJson({ + "id": "quote-id", + "expiration": "2026-04-28T12:00:00Z", + }), + throwsException, + ); + }); + + test("falls back to callback id when quote payment id is missing", () { + final details = OpenCryptoPayPaymentDetails.fromJson({ + "callback": "https://example.com/lnurl/cb/payment-id", + "quote": {"id": "quote-id", "expiration": "2026-04-28T12:00:00Z"}, + "transferAmounts": [ + { + "method": "Bitcoin", + "available": true, + "minFee": 1, + "assets": [ + {"asset": "BTC", "amount": "0.001"}, + ], + }, + ], + }); + + expect(details.quote!.paymentId, "payment-id"); + expect(details.supportsOpenCryptoPay, true); + }); +} diff --git a/test/open_crypto_pay_settlement_test.dart b/test/open_crypto_pay_settlement_test.dart new file mode 100644 index 000000000..ae3589d5b --- /dev/null +++ b/test/open_crypto_pay_settlement_test.dart @@ -0,0 +1,217 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/services/open_crypto_pay/models.dart'; +import 'package:stackwallet/services/open_crypto_pay/settlement.dart'; +import 'package:stackwallet/utilities/amount/amount.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; + +void main() { + final bitcoin = Bitcoin(CryptoCurrencyNetwork.main); + final cardano = Cardano(CryptoCurrencyNetwork.main); + final ethereum = Ethereum(CryptoCurrencyNetwork.main); + final firo = Firo(CryptoCurrencyNetwork.main); + + group("shouldCommitTxIdFor", () { + test("always uses txid for txid submission flows", () { + expect( + OpenCryptoPaySettlement.shouldCommitTxIdFor( + method: "Cardano", + submissionFlow: OpenCryptoPaySubmissionFlow.txIdAfterLocalBroadcast, + cryptoCurrency: cardano, + hasSparkInputs: false, + rawHexLength: 0, + ), + true, + ); + }); + + test("falls back for Firo Spark spends", () { + expect( + OpenCryptoPaySettlement.shouldCommitTxIdFor( + method: "Firo", + submissionFlow: OpenCryptoPaySubmissionFlow.rawHexToProvider, + cryptoCurrency: firo, + hasSparkInputs: true, + rawHexLength: 100, + ), + true, + ); + }); + + test("falls back only above the Firo raw hex query limit", () { + expect( + OpenCryptoPaySettlement.shouldCommitTxIdFor( + method: "Firo", + submissionFlow: OpenCryptoPaySubmissionFlow.rawHexToProvider, + cryptoCurrency: firo, + hasSparkInputs: false, + rawHexLength: OpenCryptoPaySettlement.maxRawHexQueryLength, + ), + false, + ); + expect( + OpenCryptoPaySettlement.shouldCommitTxIdFor( + method: "Firo", + submissionFlow: OpenCryptoPaySubmissionFlow.rawHexToProvider, + cryptoCurrency: firo, + hasSparkInputs: false, + rawHexLength: OpenCryptoPaySettlement.maxRawHexQueryLength + 1, + ), + true, + ); + }); + + test("does not use the Firo fallback for other coins", () { + expect( + OpenCryptoPaySettlement.shouldCommitTxIdFor( + method: "Bitcoin", + submissionFlow: OpenCryptoPaySubmissionFlow.rawHexToProvider, + cryptoCurrency: bitcoin, + hasSparkInputs: false, + rawHexLength: OpenCryptoPaySettlement.maxRawHexQueryLength + 1, + ), + false, + ); + }); + }); + + group("validateMinFee", () { + test("ceil-checks Ethereum wei gas price", () { + expect( + OpenCryptoPaySettlement.validateMinFee( + cryptoCurrency: ethereum, + minFee: Decimal.parse("10.1"), + gasPrice: BigInt.from(10), + ), + "Open CryptoPay requires at least 10.1 wei gas price", + ); + expect( + OpenCryptoPaySettlement.validateMinFee( + cryptoCurrency: ethereum, + minFee: Decimal.parse("10.1"), + gasPrice: BigInt.from(11), + ), + isNull, + ); + }); + + test("requires Ethereum gas price when minFee is set", () { + expect( + OpenCryptoPaySettlement.validateMinFee( + cryptoCurrency: ethereum, + minFee: Decimal.fromInt(1), + ), + "Could not verify Open CryptoPay minimum gas price", + ); + }); + + test("ceil-checks Bitcoin sat/vB against total fee", () { + expect( + OpenCryptoPaySettlement.validateMinFee( + cryptoCurrency: bitcoin, + minFee: Decimal.parse("2.5"), + fee: _rawAmount(7), + vSize: 3, + ), + "Open CryptoPay requires at least 2.5 sat/vB fee", + ); + expect( + OpenCryptoPaySettlement.validateMinFee( + cryptoCurrency: bitcoin, + minFee: Decimal.parse("2.5"), + fee: _rawAmount(8), + vSize: 3, + ), + isNull, + ); + }); + + test("requires fee and vSize for sat/vB methods", () { + expect( + OpenCryptoPaySettlement.validateMinFee( + cryptoCurrency: firo, + minFee: Decimal.fromInt(1), + ), + "Could not verify Open CryptoPay minimum fee", + ); + }); + }); + + group("validateTransaction", () { + test("requires exactly one recipient", () { + expect( + OpenCryptoPaySettlement.validateTransaction( + cryptoCurrency: bitcoin, + recipients: <({String address, Amount amount})>[], + recipientAddress: "bc1qrecipient", + amount: Decimal.fromInt(1), + ), + "Open CryptoPay requires exactly one recipient", + ); + expect( + OpenCryptoPaySettlement.validateTransaction( + cryptoCurrency: bitcoin, + recipients: [ + (address: "bc1qone", amount: _amount("1")), + (address: "bc1qtwo", amount: _amount("1")), + ], + recipientAddress: "bc1qrecipient", + amount: Decimal.fromInt(1), + ), + "Open CryptoPay requires exactly one recipient", + ); + }); + + test("rejects recipient mismatch", () { + expect( + OpenCryptoPaySettlement.validateTransaction( + cryptoCurrency: bitcoin, + recipients: [(address: "bc1qactual", amount: _amount("1"))], + recipientAddress: "bc1qexpected", + amount: Decimal.fromInt(1), + ), + "Open CryptoPay recipient changed. Please scan again.", + ); + }); + + test("rejects amount mismatch", () { + expect( + OpenCryptoPaySettlement.validateTransaction( + cryptoCurrency: bitcoin, + recipients: [(address: "bc1qrecipient", amount: _amount("1.01"))], + recipientAddress: "bc1qrecipient", + amount: Decimal.fromInt(1), + ), + "Open CryptoPay amount changed. Please scan again.", + ); + }); + + test("normalizes Ethereum recipient case", () { + expect( + OpenCryptoPaySettlement.validateTransaction( + cryptoCurrency: ethereum, + recipients: [ + ( + address: "0x9C2242A0B71FD84661FD4BC56B75C90FAC6D10FC", + amount: _amount("1", fractionDigits: 18), + ), + ], + recipientAddress: "0x9c2242a0b71fd84661fd4bc56b75c90fac6d10fc", + amount: Decimal.fromInt(1), + ), + isNull, + ); + }); + }); +} + +Amount _amount(String value, {int fractionDigits = 8}) { + return Amount.fromDecimal( + Decimal.parse(value), + fractionDigits: fractionDigits, + ); +} + +Amount _rawAmount(int value, {int fractionDigits = 8}) { + return Amount(rawValue: BigInt.from(value), fractionDigits: fractionDigits); +}