1
0

qt: expose swaps to address as Submarine Payments

Exposes reverse submarine swaps to an external/specific address in the
TxEditor gui as "Submarine Payments". The user can enter a onchain
address in the Send Tab and then pay it from the lightning balance in
the send tab by enabling the Submarine Payments option in the TxEditor
dialog menu and switching to the Submarine Payment tab in the Tab bar.
This commit is contained in:
f321x
2025-11-07 10:24:05 +01:00
parent a0455f8382
commit 8a70fdcb81
7 changed files with 501 additions and 39 deletions

View File

@@ -23,50 +23,61 @@
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import asyncio
from decimal import Decimal
from functools import partial
from typing import TYPE_CHECKING, Optional, Union, Callable
from typing import TYPE_CHECKING, Optional, Union
from electrum_aionostr.util import from_nip19
from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtGui import QIcon
from PyQt6.QtWidgets import QHBoxLayout, QVBoxLayout, QLabel, QGridLayout, QPushButton, QToolButton, QMenu, QComboBox
from PyQt6.QtWidgets import (QHBoxLayout, QVBoxLayout, QLabel, QGridLayout, QPushButton, QToolButton,
QComboBox, QTabWidget, QWidget, QStackedWidget)
from electrum.i18n import _
from electrum.util import NotEnoughFunds, NoDynamicFeeEstimates
from electrum.util import quantize_feerate
from electrum.util import (quantize_feerate, profiler, NotEnoughFunds, NoDynamicFeeEstimates,
get_asyncio_loop, wait_for2, nostr_pow_worker)
from electrum.plugin import run_hook
from electrum.transaction import Transaction, PartialTransaction
from electrum.transaction import PartialTransaction, TxOutput
from electrum.wallet import InternalAddressCorruption
from electrum.bitcoin import DummyAddress
from electrum.fee_policy import FeePolicy, FixedFeePolicy, FeeMethod
from electrum.logging import Logger
from electrum.submarine_swaps import NostrTransport, HttpTransport
from .seed_dialog import seed_warning_msg
from .util import (WindowModalDialog, ColorScheme, HelpLabel, Buttons, CancelButton,
WWLabel, read_QIcon)
from .util import (WindowModalDialog, ColorScheme, HelpLabel, Buttons, CancelButton, WWLabel,
read_QIcon, debug_widget_layouts, qt_event_listener, QtEventListener, IconLabel)
from .transaction_dialog import TxSizeLabel, TxFiatLabel, TxInOutWidget
from .fee_slider import FeeSlider, FeeComboBox
from .amountedit import FeerateEdit, BTCAmountEdit
from .locktimeedit import LockTimeEdit
from .my_treeview import QMenuWithConfig
from .swap_dialog import SwapServerDialog
if TYPE_CHECKING:
from .main_window import ElectrumWindow
class TxEditor(WindowModalDialog):
class TxEditor(WindowModalDialog, QtEventListener, Logger):
def __init__(
self, *, title='',
window: 'ElectrumWindow',
make_tx,
output_value: Union[int, str],
payee_outputs: Optional[list[TxOutput]] = None,
allow_preview=True,
batching_candidates=None,
):
WindowModalDialog.__init__(self, window, title=title)
Logger.__init__(self)
self.main_window = window
self.make_tx = make_tx
self.output_value = output_value
# used only for submarine payments as they construct tx independently of make_tx
self.payee_outputs = payee_outputs
self.tx = None # type: Optional[PartialTransaction]
self.messages = []
self.error = '' # set by side effect
@@ -85,40 +96,87 @@ class TxEditor(WindowModalDialog):
self._base_tx = None # for batching
self.batching_candidates = batching_candidates
self.swap_manager = self.wallet.lnworker.swap_manager if self.wallet.has_lightning() else None
self.swap_transport = None # type: Optional[Union[NostrTransport, HttpTransport]]
self.ongoing_swap_transport_connection_attempt = None # type: Optional[asyncio.Task]
self.did_swap = False # used to clear the PI on send tab
self.locktime_e = LockTimeEdit(self)
self.locktime_e.valueEdited.connect(self.trigger_update)
self.locktime_label = QLabel(_("LockTime") + ": ")
self.io_widget = TxInOutWidget(self.main_window, self.wallet)
self.create_fee_controls()
vbox = QVBoxLayout()
self.setLayout(vbox)
top = self.create_top_bar(self.help_text)
grid = self.create_grid()
vbox.addLayout(top)
vbox.addLayout(grid)
vbox.addWidget(self.io_widget)
onchain_vbox = QVBoxLayout()
onchain_top = self.create_top_bar(self.help_text)
onchain_grid = self.create_grid()
onchain_vbox.addLayout(onchain_top)
onchain_vbox.addLayout(onchain_grid)
onchain_vbox.addWidget(self.io_widget)
self.message_label = WWLabel('')
self.message_label.setMinimumHeight(70)
vbox.addWidget(self.message_label)
onchain_vbox.addWidget(self.message_label)
buttons = self.create_buttons_bar()
vbox.addStretch(1)
vbox.addLayout(buttons)
onchain_buttons = self.create_buttons_bar()
onchain_vbox.addStretch(1)
onchain_vbox.addLayout(onchain_buttons)
# onchain tab is the main tab and the content is also shown if tabs are disabled
self.onchain_tab = QWidget()
self.onchain_tab.setContentsMargins(0,0,0,0)
self.onchain_tab.setLayout(onchain_vbox)
# optional submarine payment tab, the tab is only shown if the option is enabled
self.submarine_payment_tab = self.create_submarine_payment_tab()
self.tab_widget = QTabWidget()
self.tab_widget.setTabBarAutoHide(True) # hides the tab bar if there is only one tab
self.tab_widget.setContentsMargins(0, 0, 0, 0)
self.tab_widget.tabBarClicked.connect(self.on_tab_clicked)
self.main_layout = QVBoxLayout()
self.main_layout.addWidget(self.tab_widget)
self.main_layout.setContentsMargins(6, 6, 6, 6) # reduce outermost margins a bit
self.setLayout(self.main_layout)
self.set_io_visible()
self.set_fee_edit_visible()
self.set_locktime_visible()
self.update_fee_target()
self.resize(self.layout().sizeHint())
self.update_tab_visibility()
self.resize_to_fit_content()
self.timer = QTimer(self)
self.timer.setInterval(500)
self.timer.setSingleShot(False)
self.timer.timeout.connect(self.timer_actions)
self.timer.start()
self.register_callbacks()
# debug_widget_layouts(self) # enable to show red lines around all elements
def accept(self):
self._cleanup()
super().accept()
def reject(self):
self._cleanup()
super().reject()
def closeEvent(self, event):
self._cleanup()
super().closeEvent(event)
def _cleanup(self):
self.unregister_callbacks()
if self.ongoing_swap_transport_connection_attempt:
self.ongoing_swap_transport_connection_attempt.cancel()
if isinstance(self.swap_transport, NostrTransport):
asyncio.run_coroutine_threadsafe(self.swap_transport.stop(), get_asyncio_loop())
self.swap_transport = None # HTTPTransport doesn't need to be closed
def on_tab_clicked(self, index):
if self.tab_widget.widget(index) == self.submarine_payment_tab:
self.prepare_swap_transport()
def is_batching(self) -> bool:
return self._base_tx is not None
@@ -140,6 +198,13 @@ class TxEditor(WindowModalDialog):
# expected to set self.tx, self.message and self.error
raise NotImplementedError()
def create_grid(self) -> QGridLayout:
raise NotImplementedError()
@property
def help_text(self) -> str:
raise NotImplementedError()
def update_fee_target(self):
if self.fee_slider.is_active():
text = self.fee_policy.get_target_text()
@@ -230,6 +295,32 @@ class TxEditor(WindowModalDialog):
self.fee_slider.setFixedWidth(200)
self.fee_target.setFixedSize(self.feerate_e.sizeHint())
def update_tab_visibility(self):
"""Update self.tab_widget to show all tabs that are enabled."""
# first remove all tabs
while self.tab_widget.count() > 0:
self.tab_widget.removeTab(0)
# always show onchain payment tab
self.tab_widget.addTab(self.onchain_tab, _('Onchain'))
allow_swaps = self.allow_preview and self.payee_outputs # allow_preview is false for ln channel opening txs
if self.config.WALLET_ENABLE_SUBMARINE_PAYMENTS and allow_swaps:
i = self.tab_widget.addTab(self.submarine_payment_tab, _('Submarine Payment'))
tooltip = self.config.cv.WALLET_ENABLE_SUBMARINE_PAYMENTS.get_long_desc()
if len(self.payee_outputs) > 1:
self.tab_widget.setTabEnabled(i, False)
tooltip = _("Submarine Payments don't support multiple outputs (Pay-to-many).")
elif self.payee_outputs[0].value == '!':
self.tab_widget.setTabEnabled(i, False)
self.submarine_payment_tab.setEnabled(False)
tooltip = _("Submarine Payments don't support 'Max' value spends.")
self.tab_widget.tabBar().setTabToolTip(i, tooltip)
# enable document mode if there is only one tab to hide the frame
self.tab_widget.setDocumentMode(self.tab_widget.count() < 2)
self.resize_to_fit_content()
def trigger_update(self):
# set tx to None so that the ok button is disabled while we compute the new tx
self.tx = None
@@ -311,7 +402,6 @@ class TxEditor(WindowModalDialog):
feerate_color = ColorScheme.BLUE
self.fee_e.setStyleSheet(fee_color.as_stylesheet())
self.feerate_e.setStyleSheet(feerate_color.as_stylesheet())
#
self.needs_update = True
def update_fee_fields(self):
@@ -411,6 +501,7 @@ class TxEditor(WindowModalDialog):
self.pref_menu.addConfig(self.config.cv.GUI_QT_TX_EDITOR_SHOW_LOCKTIME, callback=cb)
self.pref_menu.addSeparator()
self.pref_menu.addConfig(self.config.cv.WALLET_SEND_CHANGE_TO_LIGHTNING, callback=self.trigger_update)
self.pref_menu.addConfig(self.config.cv.WALLET_ENABLE_SUBMARINE_PAYMENTS, callback=self.update_tab_visibility)
self.pref_menu.addToggle(
_('Use change addresses'),
self.toggle_use_change,
@@ -442,11 +533,12 @@ class TxEditor(WindowModalDialog):
hbox.addWidget(self.pref_button)
return hbox
@profiler(min_threshold=0.02)
def resize_to_fit_content(self):
# fixme: calling resize once is not enough...
size = self.layout().sizeHint()
self.resize(size)
self.resize(size)
# update all geometries so the updated size hints are used for size adjustment
for widget in self.findChildren(QWidget):
widget.updateGeometry()
self.adjustSize()
def toggle_use_change(self):
self.wallet.use_change = not self.wallet.use_change
@@ -593,17 +685,343 @@ class TxEditor(WindowModalDialog):
def can_pay_assuming_zero_fees(self, confirmed_only: bool) -> bool:
raise NotImplementedError
### --- Functionality for reverse submarine swaps to external address ---
def create_submarine_payment_tab(self) -> QWidget:
"""Returns widget for submarine payment functionality to be added as tab"""
tab_widget = QWidget()
vbox = QVBoxLayout(tab_widget)
# stack two views, a warning view and the regular one. The warning view is shown if
# the swap cannot be performed, e.g. due to missing liquidity.
self.submarine_stacked_widget = QStackedWidget()
# Normal layout page
normal_page = QWidget()
h = QGridLayout(normal_page)
self.submarine_lightning_send_amount_label = QLabel()
self.submarine_onchain_send_amount_label = QLabel()
self.submarine_claim_mining_fee_label = QLabel()
self.submarine_server_fee_label = QLabel()
self.submarine_we_send_label = IconLabel(text=_('You send')+':')
self.submarine_we_send_label.setIcon(read_QIcon('lightning.png'))
self.submarine_they_receive_label = IconLabel(text=_('They receive')+':')
self.submarine_they_receive_label.setIcon(read_QIcon('bitcoin.png'))
h.addWidget(self.submarine_we_send_label, 0, 0)
h.addWidget(self.submarine_lightning_send_amount_label, 0, 1)
h.addWidget(self.submarine_they_receive_label, 1, 0)
h.addWidget(self.submarine_onchain_send_amount_label, 1, 1)
h.addWidget(QLabel(_('Swap fee')+':'), 2, 0)
h.addWidget(self.submarine_server_fee_label, 2, 1, 1, 2)
h.addWidget(QLabel(_('Mining fee')+':'), 3, 0)
h.addWidget(self.submarine_claim_mining_fee_label, 3, 1, 1, 2)
# Warning layout page
warning_page = QWidget()
warning_layout = QVBoxLayout(warning_page)
self.submarine_warning_label = QLabel('')
warning_layout.addWidget(self.submarine_warning_label)
self.submarine_stacked_widget.addWidget(normal_page)
self.submarine_stacked_widget.addWidget(warning_page)
vbox.addWidget(self.submarine_stacked_widget)
vbox.addStretch(1)
self.server_button = QPushButton()
self.server_button.clicked.connect(self.choose_swap_server)
self.server_button.setEnabled(False)
self.server_button.setVisible(False)
self.submarine_ok_button = QPushButton(_('OK'))
self.submarine_ok_button.setDefault(True)
self.submarine_ok_button.setEnabled(False)
# pay button must not self.accept() as this triggers closing the transport
self.submarine_ok_button.clicked.connect(self.start_submarine_swap)
buttons = Buttons(CancelButton(self), self.submarine_ok_button)
buttons.insertWidget(0, self.server_button)
vbox.addLayout(buttons)
return tab_widget
def show_swap_transport_connection_message(self):
self.submarine_stacked_widget.setCurrentIndex(1)
self.submarine_warning_label.setText(_("Connecting, please wait..."))
self.submarine_ok_button.setEnabled(False)
def prepare_swap_transport(self):
if self.swap_transport is not None and self.swap_transport.is_connected.is_set():
# we already have a connected transport, no need to create a new one
return
if self.ongoing_swap_transport_connection_attempt \
and not self.ongoing_swap_transport_connection_attempt.done():
# another task is currently trying to connect
return
# there should only be a connected transport.
# a useless transport should get cleaned up and not stored.
assert self.swap_transport is None, "swap transport wasn't cleaned up properly"
# give user feedback that we are connection now
self.show_swap_transport_connection_message()
new_swap_transport = self.main_window.create_sm_transport()
if not new_swap_transport:
# user declined to enable Nostr and has no http server configured
self.update_submarine_tab()
return
async def connect_and_update_tab(transport):
try:
await self.initialize_swap_transport(transport)
self.update_submarine_tab()
except Exception:
self.logger.exception("failed to create swap transport")
task = asyncio.run_coroutine_threadsafe(
connect_and_update_tab(new_swap_transport),
get_asyncio_loop(),
)
# this task will get cancelled if the TxEditor gets closed
self.ongoing_swap_transport_connection_attempt = task
async def initialize_swap_transport(self, new_swap_transport):
# start the transport
if isinstance(new_swap_transport, NostrTransport):
asyncio.create_task(new_swap_transport.main_loop())
else:
assert isinstance(new_swap_transport, HttpTransport)
asyncio.create_task(new_swap_transport.get_pairs_just_once())
# wait for the transport to be connected
if not await self.wait_for_swap_transport(new_swap_transport):
return
self.swap_transport = new_swap_transport
async def wait_for_swap_transport(self, new_swap_transport: Union[HttpTransport, NostrTransport]) -> bool:
"""
Wait until we found the announcement event of the configured swap server.
If it is not found but the relay connection is established return True anyway,
the user will then need to select a different swap server.
"""
timeout = new_swap_transport.connect_timeout + 1
try:
# swap_manager.is_initialized gets set once we got pairs of the configured swap server
await wait_for2(self.swap_manager.is_initialized.wait(), timeout)
except asyncio.TimeoutError:
self.logger.debug(f"swap transport initialization timed out after {timeout} sec")
except Exception:
self.logger.exception("failed to initialize swap transport")
return False
if self.swap_manager.is_initialized.is_set():
return True
# timed out above
if self.config.SWAPSERVER_URL:
# http swapserver didn't return pairs
self.logger.error(f"couldn't request pairs from {self.config.SWAPSERVER_URL=}")
return False
elif new_swap_transport.is_connected.is_set():
assert isinstance(new_swap_transport, NostrTransport)
# couldn't find announcement of configured swapserver, maybe it is gone.
# update_submarine_tab will tell the user to select a different swap server.
return True
# we couldn't even connect to the relays, this transport is useless. maybe network issues.
return False
def choose_swap_server(self) -> None:
assert isinstance(self.swap_transport, NostrTransport), self.swap_transport
self.main_window.choose_swapserver_dialog(self.swap_transport)
self.update_submarine_tab()
def start_submarine_swap(self):
assert self.payee_outputs and len(self.payee_outputs) == 1
payee_output = self.payee_outputs[0]
assert self.expected_onchain_amount_sat is not None
assert self.lightning_send_amount_sat is not None
assert self.last_server_mining_fee_sat is not None
assert self.swap_transport.is_connected.is_set()
assert self.swap_manager.is_initialized.is_set()
self.tx = None # prevent broadcasting
self.submarine_ok_button.setEnabled(False)
coro = self.swap_manager.reverse_swap(
transport=self.swap_transport,
lightning_amount_sat=self.lightning_send_amount_sat,
expected_onchain_amount_sat=self.expected_onchain_amount_sat,
prepayment_sat=2 * self.last_server_mining_fee_sat,
claim_to_output=payee_output,
)
try:
funding_txid = self.main_window.run_coroutine_dialog(coro, _('Initiating Submarine Payment...'))
except Exception as e:
self.close()
self.main_window.show_error(_("Submarine Payment failed:") + "\n" + str(e))
return
self.did_swap = True
# accepting closes the swap transport, so it needs to happen after the swap
self.accept()
self.main_window.on_swap_result(funding_txid, is_reverse=True)
def update_submarine_tab(self):
assert self.payee_outputs, "Opened submarine payment tab without outputs?"
assert len(self.payee_outputs) == \
len([o for o in self.payee_outputs if not o.is_change and not isinstance(o.value, str)])
f = self.main_window.format_amount_and_units
self.logger.debug(f"TxEditor updating submarine tab")
if not self.swap_manager:
self.set_swap_tab_warning(_("Enable Lightning in the 'Channels' tab to use Submarine Swaps."))
return
if not self.swap_transport:
# couldn't connect to nostr relays or http server didn't respond
self.set_swap_tab_warning(_("Submarine swap provider unavailable."))
return
# Update the swapserver selection button text
if isinstance(self.swap_transport, NostrTransport):
self.server_button.setVisible(True)
self.server_button.setEnabled(True)
if self.config.SWAPSERVER_NPUB:
pubkey = from_nip19(self.config.SWAPSERVER_NPUB)['object'].hex()
self.server_button.setIcon(SwapServerDialog.pubkey_to_q_icon(pubkey))
self.server_button.setText(
f' {len(self.swap_transport.get_recent_offers())} ' +
(_('providers') if len(self.swap_transport.get_recent_offers()) != 1 else _('provider'))
)
else:
# HTTPTransport or no Network, not showing server selection button
self.server_button.setEnabled(False)
self.server_button.setVisible(False)
if not self.swap_manager.is_initialized.is_set():
# connected to nostr relays but couldn't find swapserver announcement
assert isinstance(self.swap_transport, NostrTransport), "HTTPTransport shouldn't get set if it cannot fetch pairs"
assert self.swap_transport.is_connected.is_set(), "closed transport wasn't cleaned up"
if self.config.SWAPSERVER_NPUB:
msg = _("Couldn't connect to your swap provider. Please select a different provider.")
else:
msg = _('Please select a submarine swap provider.')
self.set_swap_tab_warning(msg)
return
# update values
self.lightning_send_amount_sat = self.swap_manager.get_send_amount(
self.payee_outputs[0].value, # claim tx fee reserve gets added in get_send_amount
is_reverse=True,
)
self.last_server_mining_fee_sat = self.swap_manager.mining_fee
self.expected_onchain_amount_sat = (
self.payee_outputs[0].value + self.swap_manager.get_fee_for_txbatcher()
)
# get warning
warning_text = self.get_swap_warning()
if warning_text:
self.set_swap_tab_warning(warning_text)
return
# There is no warning, show the normal view (amounts etc.)
self.submarine_stacked_widget.setCurrentIndex(0)
# label showing the payment amount (the amount the user entered in SendTab)
self.submarine_onchain_send_amount_label.setText(f(self.payee_outputs[0].value))
# the fee we pay to claim the funding output to the onchain address, shown as "Mining Fee"
claim_tx_mining_fee = self.swap_manager.get_fee_for_txbatcher()
self.submarine_claim_mining_fee_label.setText(f(claim_tx_mining_fee))
assert self.lightning_send_amount_sat is not None
self.submarine_lightning_send_amount_label.setText(f(self.lightning_send_amount_sat))
# complete fee we pay to the server
server_fee = self.lightning_send_amount_sat - self.expected_onchain_amount_sat
self.submarine_server_fee_label.setText(f(server_fee))
self.submarine_ok_button.setEnabled(True)
def get_swap_warning(self) -> Optional[str]:
f = self.main_window.format_amount_and_units
ln_can_send = int(self.wallet.lnworker.num_sats_can_send())
if self.expected_onchain_amount_sat < self.swap_manager.get_min_amount():
return '\n'.join([
_("Payment amount below the minimum possible swap amount."),
_("You need to send a higher amount to be able to do a Submarine Payment."),
_("Minimum amount: {}").format(f(self.swap_manager.get_min_amount())),
])
too_low_outbound_liquidity_msg = '\n'.join([
_("You don't have enough outgoing capacity in your lightning channels."),
_("To add outgoing capacity you can open a new lightning channel or do a submarine swap."),
_("Your lightning channels can send: {}").format(f(ln_can_send)),
])
# prioritize showing the swap provider liquidity warning before the channel liquidity warning
# as it could be annoying for the user to be told to open a new channel just to come back to
# notice there is no provider supporting their swap amount
if self.lightning_send_amount_sat is None:
provider_liquidity = self.swap_manager.get_provider_max_forward_amount()
if provider_liquidity < self.swap_manager.get_min_amount():
provider_liquidity = 0
msg = [
_("The selected swap provider is unable to offer a forward swap of this value."),
_("In order to continue select a different provider or try to send a smaller amount."),
_("Available liquidity") + f": {f(provider_liquidity)}",
]
# we don't know exactly how much we need to send on ln yet, so we can assume 0 provider fees
probably_too_low_outbound_liquidity = self.expected_onchain_amount_sat > ln_can_send
if probably_too_low_outbound_liquidity:
msg.extend([
"",
"Please also note:",
too_low_outbound_liquidity_msg,
])
return "\n".join(msg)
# if we have lightning_send_amount_sat our provider has enough liquidity, so we know the exact
# amount we need to send including the providers fees
too_low_outbound_liquidity = self.lightning_send_amount_sat > ln_can_send
if too_low_outbound_liquidity:
return too_low_outbound_liquidity_msg
return None
def set_swap_tab_warning(self, warning: str):
msg = _('Submarine Payment not possible:') + '\n' + warning
self.submarine_warning_label.setText(msg)
self.submarine_stacked_widget.setCurrentIndex(1)
self.submarine_ok_button.setEnabled(False)
@qt_event_listener
def on_event_swap_offers_changed(self, _):
if self.ongoing_swap_transport_connection_attempt \
and not self.ongoing_swap_transport_connection_attempt.done():
return
if not self.submarine_payment_tab.isVisible():
return
self.update_submarine_tab()
class ConfirmTxDialog(TxEditor):
help_text = '' #_('Set the mining fee of your transaction')
def __init__(self, *, window: 'ElectrumWindow', make_tx, output_value: Union[int, str], allow_preview=True, batching_candidates=None):
def __init__(
self, *,
window: 'ElectrumWindow',
make_tx,
output_value: Union[int, str],
payee_outputs: Optional[list[TxOutput]] = None,
allow_preview=True,
batching_candidates=None,
):
TxEditor.__init__(
self,
window=window,
make_tx=make_tx,
output_value=output_value,
payee_outputs=payee_outputs,
title=_("New Transaction"), # todo: adapt title for channel funding tx, swaps
allow_preview=allow_preview, # false for channel funding
batching_candidates=batching_candidates,

View File

@@ -62,7 +62,7 @@ from electrum.util import (format_time, UserCancelled, profiler, bfh, InvalidPas
from electrum.bip21 import BITCOIN_BIP21_URI_SCHEME
from electrum.payment_identifier import PaymentIdentifier
from electrum.invoices import PR_PAID, Invoice
from electrum.transaction import (Transaction, PartialTxInput,
from electrum.transaction import (Transaction, PartialTxInput, TxOutput,
PartialTransaction, PartialTxOutput)
from electrum.wallet import (Multisig_Wallet, Abstract_Wallet,
sweep_preparations, InternalAddressCorruption,
@@ -1504,14 +1504,28 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
return
# we need to know the fee before we broadcast, because the txid is required
make_tx = self.mktx_for_open_channel(funding_sat=funding_sat, node_id=node_id)
funding_tx, _ = self.confirm_tx_dialog(make_tx, funding_sat, allow_preview=False)
funding_tx, _, _ = self.confirm_tx_dialog(make_tx, funding_sat, allow_preview=False)
if not funding_tx:
return
self._open_channel(connect_str, funding_sat, push_amt, funding_tx)
def confirm_tx_dialog(self, make_tx, output_value, *, allow_preview=True, batching_candidates=None) -> tuple[Optional[PartialTransaction], bool]:
d = ConfirmTxDialog(window=self, make_tx=make_tx, output_value=output_value, allow_preview=allow_preview, batching_candidates=batching_candidates)
return d.run(), d.is_preview
def confirm_tx_dialog(
self,
make_tx,
output_value, *,
payee_outputs: Optional[list[TxOutput]] = None,
allow_preview=True,
batching_candidates=None,
) -> tuple[Optional[PartialTransaction], bool, bool]:
d = ConfirmTxDialog(
window=self,
make_tx=make_tx,
output_value=output_value,
payee_outputs=payee_outputs,
allow_preview=allow_preview,
batching_candidates=batching_candidates,
)
return d.run(), d.is_preview, d.did_swap
@protected
def _open_channel(self, connect_str, funding_sat, push_amt, funding_tx, password):

View File

@@ -338,9 +338,16 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
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)
tx, is_preview, paid_with_swap = self.window.confirm_tx_dialog(
make_tx,
output_value,
payee_outputs=[o for o in outputs if not o.is_change],
batching_candidates=candidates,
)
if tx is None:
# user cancelled
if paid_with_swap:
self.do_clear()
# user cancelled or paid with swap
return
if swap_dummy_output := tx.get_dummy_output(DummyAddress.SWAP):

View File

@@ -301,7 +301,7 @@ class SwapDialog(WindowModalDialog, QtEventListener):
self.needs_tx_update = True
# update icon
pubkey = from_nip19(self.config.SWAPSERVER_NPUB)['object'].hex() if self.config.SWAPSERVER_NPUB else ''
self.server_button.setIcon(SwapServerDialog._pubkey_to_q_icon(pubkey))
self.server_button.setIcon(SwapServerDialog.pubkey_to_q_icon(pubkey))
def get_client_swap_limits_sat(self) -> Tuple[int, int]:
"""Returns the (min, max) client swap limits in sat."""
@@ -531,12 +531,12 @@ class SwapServerDialog(WindowModalDialog, QtEventListener):
labels[self.Columns.LAST_SEEN] = age(x.timestamp)
item = QTreeWidgetItem(labels)
item.setData(self.Columns.PUBKEY, ROLE_NPUB, x.server_npub)
item.setIcon(self.Columns.PUBKEY, self._pubkey_to_q_icon(x.server_pubkey))
item.setIcon(self.Columns.PUBKEY, self.pubkey_to_q_icon(x.server_pubkey))
items.append(item)
self.servers_list.insertTopLevelItems(0, items)
@staticmethod
def _pubkey_to_q_icon(server_pubkey: str) -> QIcon:
def pubkey_to_q_icon(server_pubkey: str) -> QIcon:
color = QColor(*pubkey_to_rgb_color(server_pubkey))
color_pixmap = QPixmap(100, 100)
color_pixmap.fill(color)

View File

@@ -1522,6 +1522,21 @@ def set_windows_os_screenshot_protection_drm_flag(window: QWidget) -> None:
except Exception:
_logger.exception(f"failed to set windows screenshot protection flag")
def debug_widget_layouts(gui_element: QObject):
"""Draw red borders around all widgets of given QObject for debugging.
E.g. add util.debug_widget_layouts(self) at the end of TxEditor.__init__
"""
assert isinstance(gui_element, QObject) and hasattr(gui_element, 'findChildren')
def set_border(widget):
if widget is not None:
widget.setStyleSheet(widget.styleSheet() + " * { border: 1px solid red; }")
# Apply to all child widgets recursively
for widget in gui_element.findChildren(QWidget):
set_border(widget)
class _ABCQObjectMeta(type(QObject), ABCMeta): pass
class _ABCQWidgetMeta(type(QWidget), ABCMeta): pass
class AbstractQObject(QObject, ABC, metaclass=_ABCQObjectMeta): pass