993 lines
43 KiB
Python
993 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'
|
|
+ _('The amount will be displayed in red if you do not have enough funds in your wallet.') + ' '
|
|
+ _('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)}")
|