From f3a5c12fb0f05dbbfe009753c16efe52b26e1cd2 Mon Sep 17 00:00:00 2001 From: Erdem Yerebasmaz Date: Tue, 4 Aug 2026 00:28:14 +0300 Subject: [PATCH] send_payment: add Liquid address payments Boltz is currently down, which blocks every swap-based path in the SDK and leaves users unable to move funds out of their wallet. Sending directly to a Liquid address needs no swap provider, so it is the one outbound path that still works. The SDK already handles this end to end: prepare_send_payment resolves InputType::LiquidAddress natively, including drain and fee estimation. Only the app side was missing. - Parse InputType_LiquidAddress into a new LiquidAddressInputState - Add LiquidAddressPaymentPage: amount entry with a "use all funds" toggle, then a confirmation step showing recipient, amount and fees - Route the new input state through InputHandler to the existing showProcessingPaymentSheet / sendPayment pipeline - List Liquid Address in the payment info hint text Scope is L-BTC only. Addresses that name an asset are rejected as unsupported input, since paying them would require PayAmount_Asset handling that Misty does not have yet. Co-Authored-By: Claude Opus 5 --- lib/app/routes/routes.dart | 7 + lib/cubit/input/input_cubit.dart | 2 + lib/cubit/input/input_state.dart | 28 ++ .../input_handler/src/input_handler.dart | 33 ++ .../enter_payment_info_page.dart | 2 +- .../liquid/liquid_address_payment_page.dart | 354 ++++++++++++++++++ lib/routes/send_payment/send_payment.dart | 1 + 7 files changed, 426 insertions(+), 1 deletion(-) create mode 100644 lib/routes/send_payment/liquid/liquid_address_payment_page.dart diff --git a/lib/app/routes/routes.dart b/lib/app/routes/routes.dart index 09aaf22aa..569500e36 100644 --- a/lib/app/routes/routes.dart +++ b/lib/app/routes/routes.dart @@ -103,6 +103,13 @@ Route? onGenerateRoute({ ), settings: settings, ); + case LiquidAddressPaymentPage.routeName: + return FadeInRoute( + builder: (BuildContext _) => LiquidAddressPaymentPage( + addressData: settings.arguments as LiquidAddressData, + ), + settings: settings, + ); case LnPaymentPage.routeName: return FadeInRoute( builder: (BuildContext context) => BlocProvider( diff --git a/lib/cubit/input/input_cubit.dart b/lib/cubit/input/input_cubit.dart index 6650d2e2c..0ba952ec8 100644 --- a/lib/cubit/input/input_cubit.dart +++ b/lib/cubit/input/input_cubit.dart @@ -79,6 +79,8 @@ class InputCubit extends Cubit { result = InputState.nodeId(parsedInput.nodeId, source); } else if (parsedInput is InputType_BitcoinAddress) { result = InputState.bitcoinAddress(parsedInput.address, source); + } else if (parsedInput is InputType_LiquidAddress) { + result = InputState.liquidAddress(parsedInput.address, source); } else if (parsedInput is InputType_Url) { result = InputState.url(parsedInput.url, source); } else { diff --git a/lib/cubit/input/input_state.dart b/lib/cubit/input/input_state.dart index 09402ce51..e9b61eacb 100644 --- a/lib/cubit/input/input_state.dart +++ b/lib/cubit/input/input_state.dart @@ -6,6 +6,8 @@ typedef TypeCheck = bool Function(InputType); final List unsupportedInputTypeChecks = [ (InputType input) => input is InputType_NodeId, (InputType input) => input is InputType_Url, + // ponytail: Liquid sends are L-BTC only, so an address naming an asset is unsupported. + (InputType input) => input is InputType_LiquidAddress && input.address.assetId != null, ]; const Set unsupportedInputStates = {NodeIdInputState, UrlInputState}; @@ -37,6 +39,9 @@ class InputState { const factory InputState.bitcoinAddress(BitcoinAddressData data, InputSource source) = BitcoinAddressInputState; + const factory InputState.liquidAddress(LiquidAddressData data, InputSource source) = + LiquidAddressInputState; + const factory InputState.url(String url, InputSource source) = UrlInputState; } @@ -260,6 +265,29 @@ class BitcoinAddressInputState extends InputState { int get hashCode => Object.hash(data, source); } +class LiquidAddressInputState extends InputState { + const LiquidAddressInputState(this.data, this.source) : super._(); + + final LiquidAddressData data; + final InputSource source; + + @override + String toString() { + return 'LiquidAddressInputState{address: ${data.address}, source: $source}'; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is LiquidAddressInputState && + runtimeType == other.runtimeType && + data == other.data && + source == other.source; + + @override + int get hashCode => Object.hash(data, source); +} + class UrlInputState extends InputState { const UrlInputState(this.url, this.source) : super._(); diff --git a/lib/handlers/input_handler/src/input_handler.dart b/lib/handlers/input_handler/src/input_handler.dart index 04605922d..2e7e9aef0 100644 --- a/lib/handlers/input_handler/src/input_handler.dart +++ b/lib/handlers/input_handler/src/input_handler.dart @@ -98,6 +98,8 @@ class InputHandler extends Handler { throw inputState.data.reason; } else if (inputState is BitcoinAddressInputState) { return handleBitcoinAddress(context, inputState); + } else if (inputState is LiquidAddressInputState) { + return handleLiquidAddress(context, inputState); } else if (unsupportedInputStates.contains(inputState.runtimeType)) { throw context.texts().payment_info_dialog_error_unsupported_input; } else if (inputState is EmptyInputState) { @@ -187,6 +189,37 @@ class InputHandler extends Handler { return await Navigator.of(context).pushNamed(SendChainSwapPage.routeName, arguments: inputState.data); } + Future handleLiquidAddress(BuildContext context, LiquidAddressInputState inputState) async { + _logger.fine('Handle Liquid Address $inputState'); + if (inputState.data.assetId != null) { + throw context.texts().payment_info_dialog_error_unsupported_input; + } + + final NavigatorState navigator = Navigator.of(context); + final SendPaymentRequest? sendPaymentRequest = await navigator.pushNamed( + LiquidAddressPaymentPage.routeName, + arguments: inputState.data, + ); + if (sendPaymentRequest == null || !context.mounted) { + return Future.value(); + } + + return await showProcessingPaymentSheet( + context, + paymentFunc: () async { + final PaymentsCubit paymentsCubit = context.read(); + return await paymentsCubit.sendPayment(prepareResponse: sendPaymentRequest.prepareResponse); + }, + ).then((dynamic result) { + if (context.mounted) { + Navigator.of(context).pushNamedAndRemoveUntil(Home.routeName, (Route route) => false); + if (result is String) { + showFlushbar(context, message: result); + } + } + }); + } + void handleResult(dynamic result) { _logger.info('Input state handled: $result'); if (result is LNURLPageResult && result.protocol != null) { diff --git a/lib/routes/enter_payment_info/enter_payment_info_page.dart b/lib/routes/enter_payment_info/enter_payment_info_page.dart index 3758d60ae..3057afc05 100644 --- a/lib/routes/enter_payment_info/enter_payment_info_page.dart +++ b/lib/routes/enter_payment_info/enter_payment_info_page.dart @@ -100,7 +100,7 @@ class _EnterPaymentInfoPageState extends State { prefixIconConstraints: BoxConstraints.tight(const Size(16, 56)), prefixIcon: const SizedBox.shrink(), contentPadding: EdgeInsets.zero, - hintText: 'Invoice | Lightning Address | BTC Address | LNURL', + hintText: 'Invoice | Lightning Address | BTC Address | Liquid Address | LNURL', hintStyle: FieldTextStyle.labelStyle.copyWith(fontSize: 14.3), floatingLabelBehavior: FloatingLabelBehavior.never, helper: Text( diff --git a/lib/routes/send_payment/liquid/liquid_address_payment_page.dart b/lib/routes/send_payment/liquid/liquid_address_payment_page.dart new file mode 100644 index 000000000..1e25faed4 --- /dev/null +++ b/lib/routes/send_payment/liquid/liquid_address_payment_page.dart @@ -0,0 +1,354 @@ +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:breez_translations/breez_translations_locales.dart'; +import 'package:breez_translations/generated/breez_translations.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_breez_liquid/flutter_breez_liquid.dart'; +import 'package:misty_breez/cubit/cubit.dart'; +import 'package:misty_breez/routes/routes.dart'; +import 'package:misty_breez/theme/theme.dart'; +import 'package:misty_breez/utils/utils.dart'; +import 'package:misty_breez/widgets/back_button.dart' as back_button; +import 'package:misty_breez/widgets/widgets.dart'; + +/// Sends L-BTC directly to a Liquid address (no swap involved). +/// +/// ponytail: L-BTC only. Addresses that name an asset are rejected upstream in +/// [InputCubit]. Add [PayAmount_Asset] handling here when Misty supports assets. +class LiquidAddressPaymentPage extends StatefulWidget { + final LiquidAddressData addressData; + final bool isConfirmation; + final bool isDrain; + final int? amountSat; + + static const String routeName = '/liquid_address_payment'; + + const LiquidAddressPaymentPage({ + required this.addressData, + this.isConfirmation = false, + this.isDrain = false, + this.amountSat, + super.key, + }); + + @override + State createState() => _LiquidAddressPaymentPageState(); +} + +class _LiquidAddressPaymentPageState extends State { + final GlobalKey _formKey = GlobalKey(); + final TextEditingController _amountController = TextEditingController(); + final FocusNode _amountFocusNode = FocusNode(); + + KeyboardDoneAction _doneAction = KeyboardDoneAction(); + + bool _isDrain = false; + bool _isCalculatingFees = false; + String errorMessage = ''; + PrepareSendResponse? _prepareResponse; + + /// Amount is fixed once it comes from the BIP21 URI or from the amount step. + int? get _fixedAmountSat => widget.amountSat ?? widget.addressData.amountSat?.toInt(); + + bool get _isFixedAmount => _fixedAmountSat != null; + + @override + void initState() { + super.initState(); + _doneAction = KeyboardDoneAction(focusNodes: [_amountFocusNode]); + _isDrain = widget.isDrain; + + WidgetsBinding.instance.addPostFrameCallback((_) async { + final int? amountSat = _fixedAmountSat; + if (amountSat != null) { + _setAmountField(amountSat); + await _prepareSendPayment(amountSat); + } + }); + } + + @override + void dispose() { + _doneAction.dispose(); + _amountController.dispose(); + _amountFocusNode.dispose(); + super.dispose(); + } + + void _setAmountField(int amountSat) { + final CurrencyState currencyState = context.read().state; + setState(() { + _amountController.text = currencyState.bitcoinCurrency.format(amountSat, includeDisplayName: false); + }); + } + + Future _prepareSendPayment(int amountSat) async { + final BreezTranslations texts = context.texts(); + final PaymentsCubit paymentsCubit = context.read(); + setState(() { + _isCalculatingFees = true; + _prepareResponse = null; + errorMessage = ''; + }); + try { + final PayAmount payAmount = _isDrain + ? const PayAmount_Drain() + : PayAmount_Bitcoin(receiverAmountSat: BigInt.from(amountSat)); + final PrepareSendResponse response = await paymentsCubit.prepareSendPayment( + req: PrepareSendRequest(destination: widget.addressData.address, amount: payAmount), + ); + setState(() { + _prepareResponse = response; + }); + if (mounted && _isDrain) { + final int balanceSat = context.read().state.walletInfo!.balanceSat.toInt(); + _setAmountField(balanceSat - (response.feesSat?.toInt() ?? 0)); + } + } catch (error) { + setState(() { + _prepareResponse = null; + errorMessage = ExceptionHandler.extractMessage(error, texts); + }); + } finally { + setState(() { + _isCalculatingFees = false; + }); + } + } + + String? _validateAmount(int amountSat) { + final BreezTranslations texts = context.texts(); + final int balanceSat = context.read().state.walletInfo!.balanceSat.toInt(); + final String? message = amountSat <= 0 + ? texts.invoice_payment_validator_error_payment_below_invoice_limit('0') + : amountSat > balanceSat + ? texts.invoice_payment_validator_error_insufficient_local_balance + : null; + setState(() { + errorMessage = message ?? ''; + }); + return message; + } + + Future _openConfirmationPage() async { + final CurrencyState currencyState = context.read().state; + final int amountSat = currencyState.bitcoinCurrency.parse(_amountController.text); + + final SendPaymentRequest? sendPaymentRequest = await Navigator.of(context).push( + FadeInRoute( + builder: (_) => LiquidAddressPaymentPage( + addressData: widget.addressData, + isConfirmation: true, + isDrain: _isDrain, + amountSat: amountSat, + ), + ), + ); + if (sendPaymentRequest != null && mounted) { + Navigator.pop(context, sendPaymentRequest); + } + } + + @override + Widget build(BuildContext context) { + final BreezTranslations texts = context.texts(); + final ThemeData themeData = Theme.of(context); + + return Scaffold( + appBar: AppBar( + leading: const back_button.BackButton(), + title: Text(texts.ln_payment_send_payment_title), + ), + body: BlocBuilder( + builder: (BuildContext context, CurrencyState currencyState) { + final int amountSat = currencyState.bitcoinCurrency.parse(_amountController.text); + + return Form( + key: _formKey, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 32, 16, 40), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_isFixedAmount) + Padding( + padding: const EdgeInsets.only(bottom: 32), + child: LnPaymentHeader( + payeeName: '', + totalAmount: amountSat, + errorMessage: errorMessage, + ), + ), + Container( + decoration: ShapeDecoration( + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + color: themeData.customData.surfaceBgColor, + ), + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildRecipient(themeData), + const Divider(height: 32.0, color: Color.fromRGBO(40, 59, 74, 0.5)), + if (_isFixedAmount) ...[ + LnPaymentAmount(amountSat: amountSat, hasError: errorMessage.isNotEmpty), + const SizedBox(height: 16.0), + LnPaymentFee( + isCalculatingFees: _isCalculatingFees, + feesSat: errorMessage.isEmpty ? _prepareResponse?.feesSat?.toInt() : null, + ), + if (errorMessage.isNotEmpty) ...[ + const SizedBox(height: 8.0), + AutoSizeText( + errorMessage, + maxLines: 3, + textAlign: TextAlign.left, + style: FieldTextStyle.labelStyle.copyWith( + color: themeData.colorScheme.error, + ), + ), + ], + ] else ...[ + AmountFormField( + context: context, + texts: texts, + bitcoinCurrency: currencyState.bitcoinCurrency, + focusNode: _amountFocusNode, + autofocus: true, + enabled: !_isDrain, + controller: _amountController, + validatorFn: _validateAmount, + errorStyle: FieldTextStyle.labelStyle.copyWith( + fontSize: 18.0, + color: themeData.colorScheme.error, + ), + returnFN: (String amountStr) async { + if (amountStr.isNotEmpty) { + _setAmountField(currencyState.bitcoinCurrency.parse(amountStr)); + _formKey.currentState?.validate(); + } + }, + onFieldSubmitted: (String amountStr) async { + _formKey.currentState?.validate(); + }, + style: FieldTextStyle.textStyle, + ), + _buildDrainSwitch(currencyState, themeData, texts), + ], + ], + ), + ), + ], + ), + ), + ), + ); + }, + ), + bottomNavigationBar: _isFixedAmount + ? _prepareResponse != null + ? SingleButtonBottomBar( + stickToBottom: true, + text: texts.ln_payment_action_send, + onPressed: () => Navigator.pop( + context, + SendPaymentRequest(prepareResponse: _prepareResponse!), + ), + ) + : errorMessage.isNotEmpty + ? SingleButtonBottomBar( + stickToBottom: true, + text: texts.ln_payment_action_close, + onPressed: () => Navigator.of(context).pop(), + ) + : const SizedBox.shrink() + : SingleButtonBottomBar( + stickToBottom: true, + text: texts.lnurl_payment_page_action_next, + onPressed: () async { + if (_formKey.currentState?.validate() ?? false) { + await _openConfirmationPage(); + } + }, + ), + ); + } + + Widget _buildRecipient(ThemeData themeData) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + // TODO(erdemyerebasmaz): Add message to Breez-Translations + 'Recipient', + style: themeData.primaryTextTheme.headlineMedium?.copyWith(fontSize: 18.0, color: Colors.white), + ), + const SizedBox(height: 8.0), + Text( + widget.addressData.address, + style: FieldTextStyle.labelStyle.copyWith(fontSize: 14.0), + ), + ], + ); + } + + Widget _buildDrainSwitch(CurrencyState currencyState, ThemeData themeData, BreezTranslations texts) { + return BlocBuilder( + builder: (BuildContext context, AccountState accountState) { + final int balanceSat = accountState.walletInfo!.balanceSat.toInt(); + return ListTile( + dense: true, + minTileHeight: 0, + contentPadding: EdgeInsets.zero, + title: Text( + texts.withdraw_funds_use_all_funds, + style: const TextStyle( + color: Colors.white, + fontSize: 18.0, + height: 1.208, + fontWeight: FontWeight.w400, + fontFamily: 'IBMPlexSans', + ), + ), + subtitle: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + '${texts.available_balance_label} ${currencyState.bitcoinCurrency.format(balanceSat)}', + style: const TextStyle( + color: Color.fromRGBO(182, 188, 193, 1), + fontSize: 16.0, + height: 1.182, + fontWeight: FontWeight.w400, + fontFamily: 'IBMPlexSans', + ), + ), + ), + trailing: Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Switch( + value: _isDrain, + activeThumbColor: Colors.white, + activeTrackColor: themeData.primaryColor, + onChanged: (bool value) { + setState(() { + _isDrain = value; + }); + if (value) { + _setAmountField(balanceSat); + } else { + setState(() { + _amountController.clear(); + }); + } + _formKey.currentState?.validate(); + }, + ), + ), + ); + }, + ); + } +} diff --git a/lib/routes/send_payment/send_payment.dart b/lib/routes/send_payment/send_payment.dart index ebccef577..029ce65a4 100644 --- a/lib/routes/send_payment/send_payment.dart +++ b/lib/routes/send_payment/send_payment.dart @@ -1,3 +1,4 @@ export 'chainswap/chainswap.dart'; export 'lightning/lightning.dart'; +export 'liquid/liquid_address_payment_page.dart'; export 'lnurl/lnurl_payment_page.dart';