1
0
Files
electrum/electrum/gui/qt/send_tab.py
f321x fb4b744142 qt: remove outdated help string in SendTab
The amount edit box is not turning red anymore if the amount is higher
than the wallet balance, so this string can be removed.
Alternatively it could be made red again but seems like nobody missed
it.
2025-10-29 18:17:06 +01:00

992 lines
43 KiB
Python

# Copyright (C) 2022 The Electrum developers
# Distributed under the MIT software license, see the accompanying
# file LICENCE or http://www.opensource.org/licenses/mit-license.php
from decimal import Decimal
from typing import Optional, TYPE_CHECKING, Sequence, List, Callable, Union, Mapping
import urllib.parse
from PyQt6.QtCore import pyqtSignal, QPoint, Qt
from PyQt6.QtWidgets import (QLabel, QVBoxLayout, QGridLayout, QHBoxLayout,
QWidget, QToolTip, QPushButton, QApplication)
from electrum.i18n import _
from electrum.logging import Logger
from electrum.bitcoin import DummyAddress
from electrum.plugin import run_hook
from electrum.util import (
NotEnoughFunds, NoDynamicFeeEstimates, parse_max_spend, UserCancelled, ChoiceItem,
UserFacingException,
)
from electrum.invoices import PR_PAID, Invoice, PR_BROADCASTING, PR_BROADCAST
from electrum.transaction import Transaction, PartialTxInput, PartialTxOutput
from electrum.network import TxBroadcastError, BestEffortRequestFailed
from electrum.payment_identifier import (PaymentIdentifierType, PaymentIdentifier,
invoice_from_payment_identifier,
payment_identifier_from_invoice, PaymentIdentifierState)
from electrum.submarine_swaps import SwapServerError
from electrum.fee_policy import FeePolicy, FixedFeePolicy
from electrum.lnurl import LNURL3Data, request_lnurl_withdraw_callback, LNURLError
from .amountedit import AmountEdit, BTCAmountEdit, SizedFreezableLineEdit
from .paytoedit import InvalidPaymentIdentifier
from .util import (WaitingDialog, HelpLabel, MessageBoxMixin, EnterButton, char_width_in_lineedit,
get_icon_camera, read_QIcon, ColorScheme, IconLabel, Spinner, Buttons, WWLabel,
add_input_actions_to_context_menu, WindowModalDialog, OkButton, CancelButton)
from .invoice_list import InvoiceList
if TYPE_CHECKING:
from .main_window import ElectrumWindow
class SendTab(QWidget, MessageBoxMixin, Logger):
resolve_done_signal = pyqtSignal(object)
finalize_done_signal = pyqtSignal(object)
notify_merchant_done_signal = pyqtSignal(object)
def __init__(self, window: 'ElectrumWindow'):
QWidget.__init__(self, window)
Logger.__init__(self)
self.app = QApplication.instance()
self.window = window
self.wallet = window.wallet
self.fx = window.fx
self.config = window.config
self.network = window.network
self.format_amount_and_units = window.format_amount_and_units
self.format_amount = window.format_amount
self.base_unit = window.base_unit
self.pending_invoice = None
# A 4-column grid layout. All the stretch is in the last column.
# The exchange rate plugin adds a fiat widget in column 2
self.send_grid = grid = QGridLayout()
grid.setSpacing(8)
grid.setColumnStretch(3, 1)
from .paytoedit import PayToEdit
self.amount_e = BTCAmountEdit(self.window.get_decimal_point)
self.payto_e = PayToEdit(self)
msg = (_("Recipient of the funds.")
+ "\n\n"
+ _("This field can contain:") + "\n"
+ _("- a Bitcoin address or BIP21 URI") + "\n"
+ _("- a Lightning invoice") + "\n"
+ _("- a label from your list of contacts") + "\n"
+ _("- an openalias") + "\n"
+ _("- an arbitrary on-chain script, e.g.:") + " script(OP_RETURN deadbeef)" + "\n"
+ "\n"
+ _("You can also pay to many outputs in a single transaction, "
"specifying one output per line.") + "\n" + _("Format: address, amount") + "\n"
+ _("To set the amount to 'max', use the '!' special character.") + "\n"
+ _("Integers weights can also be used in conjunction with '!', "
"e.g. set one amount to '2!' and another to '3!' to split your coins 40-60."))
payto_label = HelpLabel(_('Pay to'), msg)
grid.addWidget(payto_label, 0, 0)
grid.addWidget(self.payto_e, 0, 1, 1, 4)
#completer = QCompleter()
#completer.setCaseSensitivity(False)
#self.payto_e.set_completer(completer)
#completer.setModel(self.window.completions)
msg = _('Description of the transaction (not mandatory).') + '\n\n' \
+ _(
'The description is not sent to the recipient of the funds. It is stored in your wallet file, and displayed in the \'History\' tab.')
description_label = HelpLabel(_('Description'), msg)
grid.addWidget(description_label, 1, 0)
self.message_e = SizedFreezableLineEdit(width=600)
grid.addWidget(self.message_e, 1, 1, 1, 4)
msg = _('Comment for recipient')
self.comment_label = HelpLabel(_('Comment'), msg)
grid.addWidget(self.comment_label, 2, 0)
self.comment_e = SizedFreezableLineEdit(width=600)
grid.addWidget(self.comment_e, 2, 1, 1, 4)
self.comment_label.hide()
self.comment_e.hide()
msg = (_('The amount to be received by the recipient.') + ' '
+ _('Fees are paid by the sender.') + '\n\n'
+ _('Note that if you have frozen some of your addresses, the available funds will be lower than your total balance.') + '\n\n'
+ _('Keyboard shortcut: type "!" to send all your coins.'))
amount_label = HelpLabel(_('Amount'), msg)
grid.addWidget(amount_label, 3, 0)
amount_widgets = QHBoxLayout()
amount_widgets.addWidget(self.amount_e)
self.fiat_send_e = AmountEdit(self.fx.get_currency if self.fx else '')
if not self.fx or not self.fx.is_enabled():
self.fiat_send_e.setVisible(False)
amount_widgets.addWidget(self.fiat_send_e)
self.amount_e.frozen.connect(
lambda: self.fiat_send_e.setFrozen(self.amount_e.isReadOnly()))
self.window.connect_fields(self.amount_e, self.fiat_send_e)
self.max_button = EnterButton(_("Max"), self.spend_max)
btn_width = 10 * char_width_in_lineedit()
self.max_button.setFixedWidth(btn_width)
self.max_button.setCheckable(True)
self.max_button.setEnabled(False)
amount_widgets.addWidget(self.max_button)
amount_widgets.addStretch(1)
grid.addLayout(amount_widgets, 3, 1, 1, -1)
invoice_error_icon = read_QIcon("warning.png")
self.invoice_error = IconLabel(reverse=True, hide_if_empty=True)
self.invoice_error.setIcon(invoice_error_icon)
grid.addWidget(self.invoice_error, 3, 4, Qt.AlignmentFlag.AlignRight)
self.paste_button = QPushButton(_('Paste'))
self.paste_button.clicked.connect(self.do_paste)
self.paste_button.setIcon(read_QIcon('copy.png'))
self.paste_button.setToolTip(_('Paste invoice from clipboard'))
self.paste_button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.spinner = Spinner()
grid.addWidget(self.spinner, 0, 1, 1, 4, Qt.AlignmentFlag.AlignRight)
self.save_button = EnterButton(_("Save"), self.do_save_invoice)
self.save_button.setEnabled(False)
self.send_button = EnterButton(_("Pay") + "...", self.do_pay_or_get_invoice)
self.send_button.setEnabled(False)
self.clear_button = EnterButton(_("Clear"), self.do_clear)
#buttons1 = QHBoxLayout()
#buttons1.addWidget(self.paste_button)
#buttons1.addWidget(self.clear_button)
#buttons1.addStretch(1)
#grid.addLayout(buttons1, 0, 1, 1, 4)
buttons = QHBoxLayout()
buttons.addWidget(self.paste_button)
buttons.addWidget(self.clear_button)
buttons.addStretch(1)
buttons.addWidget(self.save_button)
buttons.addWidget(self.send_button)
grid.addLayout(buttons, 6, 1, 1, 4)
self.amount_e.shortcut.connect(self.spend_max)
def reset_max(text):
self.max_button.setChecked(False)
self.amount_e.textChanged.connect(self.on_amount_changed)
self.amount_e.textEdited.connect(reset_max)
self.fiat_send_e.textEdited.connect(reset_max)
self.invoices_label = QLabel(_('Invoices'))
self.invoice_list = InvoiceList(self)
self.toolbar, menu = self.invoice_list.create_toolbar_with_menu('')
add_input_actions_to_context_menu(self.payto_e, menu)
self.paytomany_menu = menu.addToggle(_("&Pay to many"), self.toggle_paytomany)
menu.addSeparator()
menu.addAction(_("Import invoices"), self.window.import_invoices)
menu.addAction(_("Export invoices"), self.window.export_invoices)
vbox0 = QVBoxLayout()
vbox0.addLayout(grid)
hbox = QHBoxLayout()
hbox.addLayout(vbox0)
hbox.addStretch(1)
vbox = QVBoxLayout(self)
vbox.addLayout(self.toolbar)
vbox.addLayout(hbox)
vbox.addStretch(1)
vbox.addWidget(self.invoices_label)
vbox.addWidget(self.invoice_list)
vbox.setStretchFactor(self.invoice_list, 1000)
self.searchable_list = self.invoice_list
self.invoice_list.update() # after parented and put into a layout, can update without flickering
run_hook('create_send_tab', grid)
self.resolve_done_signal.connect(self.on_resolve_done)
self.finalize_done_signal.connect(self.on_finalize_done)
self.notify_merchant_done_signal.connect(self.on_notify_merchant_done)
self.payto_e.paymentIdentifierChanged.connect(self._handle_payment_identifier)
self.setTabOrder(self.send_button, self.invoice_list)
def on_amount_changed(self, text):
# FIXME: implement full valid amount check to enable/disable Pay button
pi = self.payto_e.payment_identifier
if not pi:
self.send_button.setEnabled(False)
return
pi_error = pi.is_error() if pi.is_valid() else False
is_spk_script = pi.type == PaymentIdentifierType.SPK and not pi.spk_is_address
valid_amount = is_spk_script or bool(self.amount_e.get_amount())
ready_to_finalize = not pi.need_resolve()
self.send_button.setEnabled(pi.is_valid() and not pi_error and valid_amount and ready_to_finalize)
def do_paste(self):
self.logger.debug('do_paste')
try:
self.payto_e.try_payment_identifier(self.app.clipboard().text())
except InvalidPaymentIdentifier as e:
self.show_error(_('Invalid payment identifier on clipboard'))
def set_payment_identifier(self, text):
self.logger.debug('set_payment_identifier')
try:
self.payto_e.try_payment_identifier(text)
except InvalidPaymentIdentifier as e:
self.show_error(_('Invalid payment identifier'))
def spend_max(self):
pi = self.payto_e.payment_identifier
if pi is None or pi.type == PaymentIdentifierType.UNKNOWN:
return
elif pi.type not in [PaymentIdentifierType.SPK, PaymentIdentifierType.MULTILINE,
PaymentIdentifierType.BIP21, PaymentIdentifierType.OPENALIAS]:
# clear the amount field once it is clear this PI is not eligible for '!'
self.amount_e.clear()
return
if pi.type == PaymentIdentifierType.BIP21:
assert 'amount' not in pi.bip21
if run_hook('abort_send', self):
return
outputs = pi.get_onchain_outputs('!')
if not outputs:
return
make_tx = lambda fee_policy, *, confirmed_only=False: self.wallet.make_unsigned_transaction(
fee_policy=fee_policy,
coins=self.window.get_coins(),
outputs=outputs,
is_sweep=False)
try:
try:
tx = make_tx(FeePolicy(self.config.FEE_POLICY))
except (NotEnoughFunds, NoDynamicFeeEstimates) as e:
# Check if we had enough funds excluding fees,
# if so, still provide opportunity to set lower fees.
tx = make_tx(FixedFeePolicy(0))
except NotEnoughFunds as e:
self.max_button.setChecked(False)
text = self.wallet.get_text_not_enough_funds_mentioning_frozen(for_amount='!')
self.show_error(text)
return
self.max_button.setChecked(True)
amount = tx.output_value()
__, x_fee_amount = run_hook('get_tx_extra_fee', self.wallet, tx) or (None, 0)
amount_after_all_fees = amount - x_fee_amount
self.amount_e.setAmount(amount_after_all_fees)
# show tooltip explaining max amount
mining_fee = tx.get_fee()
mining_fee_str = self.format_amount_and_units(mining_fee)
msg = _("Mining fee: {} (can be adjusted on next screen)").format(mining_fee_str)
if x_fee_amount:
twofactor_fee_str = self.format_amount_and_units(x_fee_amount)
msg += "\n" + _("2fa fee: {} (for the next batch of transactions)").format(twofactor_fee_str)
frozen_bal = self.wallet.get_frozen_balance_str()
if frozen_bal:
msg += "\n" + _("Some coins are frozen: {} (can be unfrozen in the Addresses or in the Coins tab)").format(frozen_bal)
QToolTip.showText(self.max_button.mapToGlobal(QPoint(0, 0)), msg)
# TODO: instead of passing outputs, use an invoice instead (like pay_lightning_invoice)
# so we have more context (we cannot rely on send_tab field contents or payment identifier
# as this method is called from other places as well).
def pay_onchain_dialog(
self,
outputs: List[PartialTxOutput],
*,
nonlocal_only=False,
external_keypairs: Mapping[bytes, bytes] = None,
get_coins: Callable[..., Sequence[PartialTxInput]] = None,
invoice: Optional[Invoice] = None
) -> None:
# trustedcoin requires this
if run_hook('abort_send', self):
return
is_sweep = bool(external_keypairs)
# we call get_coins inside make_tx, so that inputs can be changed dynamically
if get_coins is None:
get_coins = self.window.get_coins
def make_tx(fee_policy, *, confirmed_only=False, base_tx=None):
coins = get_coins(nonlocal_only=nonlocal_only, confirmed_only=confirmed_only)
return self.wallet.make_unsigned_transaction(
fee_policy=fee_policy,
coins=coins,
outputs=outputs,
base_tx=base_tx,
is_sweep=is_sweep,
send_change_to_lightning=self.config.WALLET_SEND_CHANGE_TO_LIGHTNING,
merge_duplicate_outputs=self.config.WALLET_MERGE_DUPLICATE_OUTPUTS,
)
output_values = [x.value for x in outputs]
is_max = any(parse_max_spend(outval) for outval in output_values)
output_value = '!' if is_max else sum(output_values)
# To find batching candidates, we need to know our available UTXOs.
# Ideally should use same set of coins make_tx() will use.
# note: - prone to races: coins set might change due to new txs between now and make_tx() call
# - make_tx() might pass different params to get_coins()
# - to mitigate, we prefer to be more restrictive. hence confirmed_only=True
coins_conservative = get_coins(nonlocal_only=True, confirmed_only=True)
candidates = self.wallet.get_candidates_for_batching(outputs, coins=coins_conservative)
tx, is_preview = self.window.confirm_tx_dialog(make_tx, output_value, batching_candidates=candidates)
if tx is None:
# user cancelled
return
if swap_dummy_output := tx.get_dummy_output(DummyAddress.SWAP):
sm = self.wallet.lnworker.swap_manager
with self.window.create_sm_transport() as transport:
if not self.window.initialize_swap_manager(transport):
return
coro = sm.request_swap_for_amount(transport=transport, onchain_amount=swap_dummy_output.value)
try:
swap, swap_invoice = self.window.run_coroutine_dialog(coro, _('Requesting swap invoice...'))
except (SwapServerError, UserFacingException) as e:
self.show_error(str(e))
return
except UserCancelled:
return
tx.replace_output_address(DummyAddress.SWAP, swap.lockup_address)
assert tx.get_dummy_output(DummyAddress.SWAP) is None
tx.swap_invoice = swap_invoice
tx.swap_payment_hash = swap.payment_hash
if is_preview:
self.window.show_transaction(tx, external_keypairs=external_keypairs, invoice=invoice)
return
self.save_pending_invoice()
def sign_done(success):
if success:
self.window.broadcast_or_show(tx, invoice=invoice)
self.window.sign_tx(
tx,
callback=sign_done,
external_keypairs=external_keypairs)
def do_clear(self):
self.logger.debug('do_clear')
self.lock_fields(lock_recipient=False, lock_amount=False, lock_max=True, lock_description=False)
self.max_button.setChecked(False)
self.payto_e.do_clear()
for w in [self.comment_e, self.comment_label]:
w.setVisible(False)
for w in [self.message_e, self.amount_e, self.fiat_send_e, self.comment_e]:
w.setText('')
w.setToolTip('')
for w in [self.save_button, self.send_button]:
w.setEnabled(False)
self.window.update_status()
self.paytomany_menu.setChecked(self.payto_e.multiline)
self.invoice_error.setText('')
run_hook('do_clear', self)
def prepare_for_send_tab_network_lookup(self):
for btn in [self.save_button, self.send_button, self.clear_button]:
btn.setEnabled(False)
self.spinner.setVisible(True)
def payment_request_error(self, error):
self.show_message(error)
self.do_clear()
def set_field_validated(self, w, *, validated: Optional[bool] = None):
if validated is not None:
w.setStyleSheet(ColorScheme.GREEN.as_stylesheet(True) if validated else ColorScheme.RED.as_stylesheet(True))
def lock_fields(
self, *,
lock_recipient: Optional[bool] = None,
lock_amount: Optional[bool] = None,
lock_max: Optional[bool] = None,
lock_description: Optional[bool] = None
) -> None:
self.logger.debug(f'locking fields, r={lock_recipient}, a={lock_amount}, m={lock_max}, d={lock_description}')
if lock_recipient is not None:
self.payto_e.setFrozen(lock_recipient)
if lock_amount is not None:
self.amount_e.setFrozen(lock_amount)
if lock_max is not None:
self.max_button.setEnabled(not lock_max)
if lock_max is True:
self.max_button.setChecked(False)
if lock_description is not None:
self.message_e.setFrozen(lock_description)
def update_fields(self):
self.logger.debug('update_fields')
pi = self.payto_e.payment_identifier
self.clear_button.setEnabled(True)
if pi.is_multiline():
self.lock_fields(lock_recipient=False, lock_amount=True, lock_max=True, lock_description=False)
self.set_field_validated(self.payto_e, validated=pi.is_valid()) # TODO: validated used differently here than openalias
self.save_button.setEnabled(pi.is_valid())
self.send_button.setEnabled(pi.is_valid())
self.payto_e.setToolTip(pi.get_error() if not pi.is_valid() else '')
if pi.is_valid():
self.handle_multiline(pi.multiline_outputs)
return
if not pi.is_valid():
self.lock_fields(lock_recipient=False, lock_amount=False, lock_max=True, lock_description=False)
self.save_button.setEnabled(False)
self.send_button.setEnabled(False)
return
lock_recipient = pi.type in [PaymentIdentifierType.LNURL, PaymentIdentifierType.LNURLW,
PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR,
PaymentIdentifierType.OPENALIAS, PaymentIdentifierType.BIP70,
PaymentIdentifierType.BIP21, PaymentIdentifierType.BOLT11] and not pi.need_resolve()
lock_amount = pi.is_amount_locked()
lock_max = lock_amount or pi.type not in [PaymentIdentifierType.SPK, PaymentIdentifierType.BIP21]
self.lock_fields(lock_recipient=lock_recipient,
lock_amount=lock_amount,
lock_max=lock_max,
lock_description=False)
if lock_recipient:
fields = pi.get_fields_for_GUI()
if fields.recipient:
self.payto_e.setText(fields.recipient)
if fields.description:
self.message_e.setText(fields.description)
self.lock_fields(lock_description=True)
if fields.amount:
self.amount_e.setAmount(fields.amount)
for w in [self.comment_e, self.comment_label]:
w.setVisible(bool(fields.comment))
if fields.comment:
self.comment_e.setToolTip(_('Max comment length: {} characters').format(fields.comment))
self.set_field_validated(self.payto_e, validated=fields.validated)
# LNURLp amount range
if fields.amount_range:
amin, amax = fields.amount_range
self.amount_e.setToolTip(_('Amount must be between {} and {} sat.').format(amin, amax))
else:
self.amount_e.setToolTip('')
# resolve '!' in amount editor if it was set before PI
if not lock_max and self.amount_e.text() == '!':
self.spend_max()
elif lock_max and self.amount_e.text() == '!':
self.amount_e.clear()
pi_unusable = pi.is_error() or (not self.wallet.has_lightning() and not pi.is_onchain())
is_spk_script = pi.type == PaymentIdentifierType.SPK and not pi.spk_is_address
amount_valid = is_spk_script or bool(self.amount_e.get_amount())
self.send_button.setEnabled(not pi_unusable and amount_valid and not pi.has_expired())
self.save_button.setEnabled(not pi_unusable and not is_spk_script and not pi.has_expired() and \
pi.type not in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR])
self.invoice_error.setText(_('Expired') if pi.has_expired() else '')
def _handle_payment_identifier(self):
self.update_fields()
if not self.payto_e.payment_identifier.is_valid():
self.logger.debug(f'PI error: {self.payto_e.payment_identifier.error}')
return
if self.payto_e.payment_identifier.need_resolve():
self.prepare_for_send_tab_network_lookup()
self.payto_e.payment_identifier.resolve(on_finished=self.resolve_done_signal.emit)
def on_resolve_done(self, pi: 'PaymentIdentifier'):
# TODO: resolve can happen while typing, we don't want message dialogs to pop up
# currently we don't set error for emaillike recipients to avoid just that
self.logger.debug('payment identifier resolve done')
self.spinner.setVisible(False)
if pi.error:
self.show_error(pi.error)
self.do_clear()
return
if pi.type == PaymentIdentifierType.LNURLW:
assert pi.state == PaymentIdentifierState.LNURLW_FINALIZE, \
f"Detected LNURLW but not ready to finalize? {pi=}"
self.do_clear()
self.request_lnurl_withdraw_dialog(pi.lnurl_data)
return
# if openalias add openalias to contacts
if pi.type == PaymentIdentifierType.OPENALIAS:
key = pi.emaillike if pi.emaillike else pi.domainlike
pi.contacts[key] = ('openalias', pi.openalias_data.get('name'))
self.update_fields()
def get_message(self):
return self.message_e.text()
def read_invoice(self) -> Optional[Invoice]:
if self.check_payto_line_and_show_errors():
return
amount_sat = self.read_amount()
invoice = invoice_from_payment_identifier(
self.payto_e.payment_identifier, self.wallet, amount_sat, self.get_message())
if not invoice:
self.show_error('error getting invoice' + self.payto_e.payment_identifier.error)
return
if not self.wallet.has_lightning() and not invoice.can_be_paid_onchain():
self.show_error(_('Lightning is disabled'))
if self.wallet.get_invoice_status(invoice) == PR_PAID:
# fixme: this is only for bip70 and lightning
self.show_error(_('Invoice already paid'))
return
#if not invoice.is_lightning():
# if self.check_onchain_outputs_and_show_errors(outputs):
# return
return invoice
def do_save_invoice(self):
self.pending_invoice = self.read_invoice()
if not self.pending_invoice:
return
self.save_pending_invoice()
def save_pending_invoice(self):
if not self.pending_invoice:
return
self.do_clear()
self.wallet.save_invoice(self.pending_invoice)
self.invoice_list.update()
self.pending_invoice = None
def get_amount(self) -> int:
# must not be None
return self.amount_e.get_amount() or 0
def on_finalize_done(self, pi: PaymentIdentifier):
self.spinner.setVisible(False)
self.update_fields()
if pi.error:
self.show_error(pi.error)
return
invoice = pi.bolt11
self.pending_invoice = invoice
self.logger.debug(f'after finalize invoice: {invoice!r}')
self.do_pay_invoice(invoice)
def do_pay_or_get_invoice(self):
pi = self.payto_e.payment_identifier
if pi.need_finalize():
self.prepare_for_send_tab_network_lookup()
pi.finalize(amount_sat=self.get_amount(), comment=self.comment_e.text(),
on_finished=self.finalize_done_signal.emit)
return
self.pending_invoice = self.read_invoice()
if not self.pending_invoice:
return
self.do_pay_invoice(self.pending_invoice)
def pay_multiple_invoices(self, invoices):
outputs = []
for invoice in invoices:
outputs += invoice.outputs
self.pay_onchain_dialog(outputs)
def do_edit_invoice(self, invoice: 'Invoice'): # FIXME broken
assert not bool(invoice.get_amount_sat())
text = invoice.lightning_invoice if invoice.is_lightning() else invoice.get_address()
self.set_payment_identifier(text)
self.amount_e.setFocus()
# disable save button, because it would create a new invoice
self.save_button.setEnabled(False)
def do_pay_invoice(self, invoice: 'Invoice'):
if not bool(invoice.get_amount_sat()):
pi = self.payto_e.payment_identifier
if pi.type == PaymentIdentifierType.SPK and not pi.spk_is_address:
pass
else:
self.show_error(_('No amount'))
return
if invoice.is_lightning():
self.pay_lightning_invoice(invoice)
else:
self.pay_onchain_dialog(invoice.outputs, invoice=invoice)
def read_amount(self) -> Union[int, str]:
amount = '!' if self.max_button.isChecked() else self.get_amount()
return amount
def check_onchain_outputs_and_show_errors(self, outputs: List[PartialTxOutput]) -> bool:
"""Returns whether there are errors with outputs.
Also shows error dialog to user if so.
"""
if not outputs:
self.show_error(_('No outputs'))
return True
for o in outputs:
if o.scriptpubkey is None:
self.show_error(_('Bitcoin Address is None'))
return True
if o.value is None:
self.show_error(_('Invalid Amount'))
return True
return False # no errors
def check_payto_line_and_show_errors(self) -> bool:
"""Returns whether there are errors.
Also shows error dialog to user if so.
"""
error = self.payto_e.payment_identifier.get_error()
if error:
if not self.payto_e.payment_identifier.is_multiline():
err = error
self.show_warning(
_("Failed to parse 'Pay to' line") + ":\n" +
f"{err.line_content[:40]}...\n\n"
f"{err.exc!r}")
else:
self.show_warning(
_("Invalid Lines found:") + "\n\n" + error)
#'\n'.join([_("Line #") +
# f"{err.idx+1}: {err.line_content[:40]}... ({err.exc!r})"
# for err in errors]))
return True
warning = self.payto_e.payment_identifier.warning
if warning:
warning += '\n' + _('Do you wish to continue?')
if not self.question(warning):
return True
if self.payto_e.payment_identifier.has_expired():
self.show_error(_('Payment request has expired'))
return True
return False # no errors
def pay_lightning_invoice(self, invoice: Invoice):
amount_sat = invoice.get_amount_sat()
if amount_sat is None:
raise Exception("missing amount for LN invoice")
# note: lnworker might be None if LN is disabled,
# in which case we should still offer the user to pay onchain.
lnworker = self.wallet.lnworker
if lnworker is None or not lnworker.can_pay_invoice(invoice):
coins = self.window.get_coins(nonlocal_only=True)
can_pay_with_new_channel = False
can_pay_with_swap = False
can_rebalance = False
if lnworker:
can_pay_with_new_channel = lnworker.suggest_funding_amount(amount_sat, coins=coins)
can_pay_with_swap = lnworker.suggest_swap_to_send(amount_sat, coins=coins)
rebalance_suggestion = lnworker.suggest_rebalance_to_send(amount_sat)
can_rebalance = bool(rebalance_suggestion) and self.window.num_tasks() == 0
choices = [] # type: List[ChoiceItem]
if can_rebalance:
msg = ''.join([
_('Rebalance existing channels'), '\n',
_('Move funds between your channels in order to increase your sending capacity.')
])
choices.append(ChoiceItem(key='rebalance', label=msg))
if can_pay_with_new_channel:
msg = ''.join([
_('Open a new channel'), '\n',
_('You will be able to pay once the channel is open.')
])
choices.append(ChoiceItem(key='new_channel', label=msg))
if can_pay_with_swap:
msg = ''.join([
_('Swap onchain funds for lightning funds'), '\n',
_('You will be able to pay once the swap is confirmed.')
])
choices.append(ChoiceItem(key='swap', label=msg))
msg = _('You cannot pay that invoice using Lightning.')
if lnworker and lnworker.channels:
num_sats_can_send = int(lnworker.num_sats_can_send())
msg += '\n' + _('Your channels can send {}.').format(self.format_amount(num_sats_can_send) + ' ' + self.base_unit())
if not choices:
self.window.show_error(msg)
return
r = self.window.query_choice(msg, choices)
if r is not None:
self.save_pending_invoice()
if r == 'rebalance':
chan1, chan2, delta = rebalance_suggestion
self.window.rebalance_dialog(chan1, chan2, amount_sat=delta)
elif r == 'new_channel':
amount_sat, min_amount_sat = can_pay_with_new_channel
self.window.new_channel_dialog(amount_sat=amount_sat, min_amount_sat=min_amount_sat)
elif r == 'swap':
chan, swap_recv_amount_sat = can_pay_with_swap
self.window.run_swap_dialog(is_reverse=False, recv_amount_sat_or_max=swap_recv_amount_sat, channels=[chan])
elif r == 'onchain':
self.pay_onchain_dialog(invoice.get_outputs(), nonlocal_only=True, invoice=invoice)
return
assert lnworker is not None
# FIXME this is currently lying to user as we truncate to satoshis
amount_msat = invoice.get_amount_msat()
msg = _("Pay lightning invoice?") + '\n\n' + _("This will send {}?").format(self.format_amount_and_units(Decimal(amount_msat)/1000))
if not self.question(msg):
return
self.save_pending_invoice()
coro = lnworker.pay_invoice(invoice, amount_msat=amount_msat)
self.window.run_coroutine_from_thread(coro, _('Sending payment'))
def broadcast_transaction(self, tx: Transaction, *, invoice: Invoice = None):
if hasattr(tx, 'swap_payment_hash'):
sm = self.wallet.lnworker.swap_manager
swap = sm.get_swap(tx.swap_payment_hash)
with sm.create_transport() as transport:
coro = sm.wait_for_htlcs_and_broadcast(
transport=transport, swap=swap, invoice=tx.swap_invoice, tx=tx)
try:
funding_txid = self.window.run_coroutine_dialog(coro, _('Awaiting lightning payment...'))
except UserCancelled:
sm.cancel_normal_swap(swap)
return
self.window.on_swap_result(funding_txid, is_reverse=False)
def broadcast_thread():
# non-GUI thread
if invoice and invoice.has_expired():
return False, _("Invoice has expired")
try:
self.network.run_from_another_thread(self.network.broadcast_transaction(tx))
except TxBroadcastError as e:
return False, e.get_message_for_gui()
except BestEffortRequestFailed as e:
return False, repr(e)
# success
if invoice and invoice.bip70:
payment_identifier = payment_identifier_from_invoice(invoice)
# FIXME: this should move to backend
if payment_identifier and payment_identifier.need_merchant_notify():
refund_address = self.wallet.get_receiving_address()
payment_identifier.notify_merchant(
tx=tx,
refund_address=refund_address,
on_finished=self.notify_merchant_done_signal.emit
)
return True, tx.txid()
# Capture current TL window; override might be removed on return
parent = self.window.top_level_window(lambda win: isinstance(win, MessageBoxMixin))
# FIXME: move to backend and let Abstract_Wallet set broadcasting state, not gui
self.wallet.set_broadcasting(tx, broadcasting_status=PR_BROADCASTING)
def broadcast_done(result):
# GUI thread
if result:
success, msg = result
if success:
parent.show_message(_('Payment sent.') + '\n' + msg)
self.invoice_list.update()
self.wallet.set_broadcasting(tx, broadcasting_status=PR_BROADCAST)
else:
msg = msg or ''
parent.show_error(msg)
self.wallet.set_broadcasting(tx, broadcasting_status=None)
WaitingDialog(self, _('Broadcasting transaction...'),
broadcast_thread, broadcast_done, self.window.on_error)
def on_notify_merchant_done(self, pi: PaymentIdentifier):
if pi.is_error():
self.logger.debug(f'merchant notify error: {pi.get_error()}')
else:
self.logger.debug(f'merchant notify result: {pi.merchant_ack_status}: {pi.merchant_ack_message}')
# TODO: show user? if we broadcasted the tx successfully, do we care?
# BitPay complains with a NAK if tx is RbF
def toggle_paytomany(self):
self.payto_e.toggle_paytomany()
if self.payto_e.is_paytomany():
message = '\n'.join([
_('Enter a list of outputs in the \'Pay to\' field.'),
_('One output per line.'),
_('Format: address, amount'),
_('You may load a CSV file using the file icon.')
])
self.window.show_tooltip_after_delay(message)
def payto_contacts(self, labels):
paytos = [self.window.get_contact_payto(label) for label in labels]
self.window.show_send_tab()
self.do_clear()
if len(paytos) == 1:
self.logger.debug('payto_e setText 1')
self.payto_e.setText(paytos[0])
self.amount_e.setFocus()
else:
self.payto_e.setFocus()
text = "\n".join([payto + ", 0" for payto in paytos])
self.logger.debug('payto_e setText n')
self.payto_e.setText(text)
self.payto_e.setFocus()
def handle_multiline(self, outputs):
total = 0
for output in outputs:
if parse_max_spend(output.value):
self.max_button.setChecked(True) # TODO: remove and let spend_max set this?
self.spend_max()
return
else:
total += output.value
self.amount_e.setAmount(total if outputs else None)
def request_lnurl_withdraw_dialog(self, lnurl_data: LNURL3Data):
if not self.wallet.has_lightning():
self.show_error(
_("Cannot request lightning withdrawal, wallet has no lightning channels.")
)
return
dialog = WindowModalDialog(self, _("Lightning Withdrawal"))
dialog.setMinimumWidth(400)
vbox = QVBoxLayout()
dialog.setLayout(vbox)
grid = QGridLayout()
grid.setSpacing(8)
grid.setColumnStretch(3, 1) # Make the last column stretch
row = 0
# provider url
domain_label = QLabel(_("Provider") + ":")
domain_text = WWLabel(urllib.parse.urlparse(lnurl_data.callback_url).netloc)
grid.addWidget(domain_label, row, 0)
grid.addWidget(domain_text, row, 1, 1, 3)
row += 1
if lnurl_data.default_description:
desc_label = QLabel(_("Description") + ":")
desc_text = WWLabel(lnurl_data.default_description)
grid.addWidget(desc_label, row, 0)
grid.addWidget(desc_text, row, 1, 1, 3)
row += 1
min_amount = max(lnurl_data.min_withdrawable_sat, 1)
max_amount = min(
lnurl_data.max_withdrawable_sat,
int(self.wallet.lnworker.num_sats_can_receive())
)
min_text = self.format_amount_and_units(lnurl_data.min_withdrawable_sat)
if min_amount > int(self.wallet.lnworker.num_sats_can_receive()):
self.show_error("".join([
_("Too little incoming liquidity to satisfy this withdrawal request."), "\n\n",
_("Can receive: {}").format(
self.format_amount_and_units(self.wallet.lnworker.num_sats_can_receive()),
), "\n",
_("Minimum withdrawal amount: {}").format(min_text), "\n\n",
_("Do a submarine swap in the 'Channels' tab to get more incoming liquidity.")
]))
return
is_fixed_amount = lnurl_data.min_withdrawable_sat == lnurl_data.max_withdrawable_sat
# Range information (only for non-fixed amounts)
if not is_fixed_amount:
range_label_text = QLabel(_("Range") + ":")
range_value = QLabel("{} - {}".format(
min_text,
self.format_amount_and_units(lnurl_data.max_withdrawable_sat)
))
grid.addWidget(range_label_text, row, 0)
grid.addWidget(range_value, row, 1, 1, 2)
row += 1
# Amount section
amount_label = QLabel(_("Amount") + ":")
amount_edit = BTCAmountEdit(self.window.get_decimal_point, max_amount=max_amount)
amount_edit.setAmount(max_amount)
grid.addWidget(amount_label, row, 0)
grid.addWidget(amount_edit, row, 1)
if is_fixed_amount:
# Fixed amount, just show the amount
amount_edit.setDisabled(True)
else:
# Range, show max button
max_button = EnterButton(_("Max"), lambda: amount_edit.setAmount(max_amount))
btn_width = 10 * char_width_in_lineedit()
max_button.setFixedWidth(btn_width)
grid.addWidget(max_button, row, 2)
row += 1
# Warning for insufficient liquidity
if lnurl_data.max_withdrawable_sat > int(self.wallet.lnworker.num_sats_can_receive()):
warning_text = WWLabel(
_("The maximum withdrawable amount is larger than what your channels can receive. "
"You may need to do a submarine swap to increase your incoming liquidity.")
)
warning_text.setStyleSheet("color: orange;")
grid.addWidget(warning_text, row, 0, 1, 4)
row += 1
vbox.addLayout(grid)
# Buttons
request_button = OkButton(dialog, _("Request Withdrawal"))
cancel_button = CancelButton(dialog)
vbox.addLayout(Buttons(cancel_button, request_button))
# Show dialog and handle result
if dialog.exec():
if is_fixed_amount:
amount_sat = lnurl_data.max_withdrawable_sat
else:
amount_sat = amount_edit.get_amount()
if not amount_sat or not (min_amount <= int(amount_sat) <= max_amount):
self.show_error(_("Enter a valid amount. You entered: {}").format(amount_sat))
return
else:
return
try:
key = self.wallet.create_request(
amount_sat=amount_sat,
message=lnurl_data.default_description,
exp_delay=120,
address=None,
)
req = self.wallet.get_request(key)
info = self.wallet.lnworker.get_payment_info(req.payment_hash)
_lnaddr, b11_invoice = self.wallet.lnworker.get_bolt11_invoice(
payment_info=info,
message=req.get_message(),
fallback_address=None,
)
except Exception as e:
self.logger.exception('')
self.show_error(
f"{_('Failed to create payment request for withdrawal')}: {str(e)}"
)
return
coro = request_lnurl_withdraw_callback(
callback_url=lnurl_data.callback_url,
k1=lnurl_data.k1,
bolt_11=b11_invoice
)
try:
self.window.run_coroutine_dialog(coro, _("Requesting lightning withdrawal..."))
except LNURLError as e:
self.show_error(f"{_('Failed to request withdrawal')}:\n{str(e)}")