Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
444 changes: 416 additions & 28 deletions electrum/gui/qt/confirm_tx_dialog.py

Large diffs are not rendered by default.

24 changes: 19 additions & 5 deletions electrum/gui/qt/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
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,
Expand Down Expand Up @@ -1504,14 +1504,28 @@ def open_channel(self, connect_str, funding_sat, push_amt):
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):
Expand Down
19 changes: 17 additions & 2 deletions electrum/gui/qt/send_tab.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,9 +338,24 @@ def make_tx(fee_policy, *, confirmed_only=False, base_tx=None):
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)
submarine_payment_payee_outputs = [o for o in outputs if not o.is_change]
if is_max and submarine_payment_payee_outputs:
# replace '!' max value with amount_e int value as the swap logic should use the same
# amount that is shown here in the send tab. It can be set on the first value as
# submarine payments are only possible with a single TxOutput and will err if
# there are more outputs anyway.
submarine_payment_payee_outputs[0].value = int(self.amount_e.get_amount() or 0)

tx, is_preview, paid_with_swap = self.window.confirm_tx_dialog(
make_tx,
output_value,
payee_outputs=submarine_payment_payee_outputs,
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):
Expand Down
6 changes: 3 additions & 3 deletions electrum/gui/qt/swap_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ def update(self):
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."""
Expand Down Expand Up @@ -531,12 +531,12 @@ def update_servers_list(self, servers: Sequence['SwapOffer']):
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)
Expand Down
15 changes: 15 additions & 0 deletions electrum/gui/qt/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions electrum/simple_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,15 @@ def __setattr__(self, name, value):
short_desc=lambda: _('Send change to Lightning'),
long_desc=lambda: _('If possible, send the change of this transaction to your channels, with a submarine swap'),
)
WALLET_ENABLE_SUBMARINE_PAYMENTS = ConfigVar(
'enable_submarine_payments', default=False, type_=bool,
short_desc=lambda: _('Submarine Payments'),
long_desc=lambda: _('Send onchain payments directly from your Lightning balance with a '
'submarine swap. This allows you to do onchain transactions even if your entire '
'wallet balance is inside Lightning channels. Submarine payments are '
'uncorrelated to the local wallet history as they use onchain funds of the '
'service provider, providing improved privacy for the payer.')
)
WALLET_FREEZE_REUSED_ADDRESS_UTXOS = ConfigVar(
'wallet_freeze_reused_address_utxos', default=False, type_=bool,
short_desc=lambda: _('Avoid spending from used addresses'),
Expand Down
100 changes: 81 additions & 19 deletions electrum/submarine_swaps.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,12 @@
from .i18n import _
from .logging import Logger
from .crypto import sha256, ripemd
from .bitcoin import script_to_p2wsh, opcodes, dust_threshold, DummyAddress, construct_witness, construct_script
from .bitcoin import (script_to_p2wsh, opcodes, dust_threshold, DummyAddress, construct_witness,
construct_script, address_to_script)
from . import bitcoin
from .transaction import (
PartialTxInput, PartialTxOutput, PartialTransaction, Transaction, TxInput, TxOutpoint, script_GetOp,
match_script_against_template, OPPushDataGeneric, OPPushDataPubkey
match_script_against_template, OPPushDataGeneric, OPPushDataPubkey, TxOutput,
)
from .util import (
log_exceptions, ignore_exceptions, BelowDustLimit, OldTaskGroup, ca_path, gen_nostr_ann_pow,
Expand Down Expand Up @@ -199,7 +200,7 @@ class SwapData(StoredObject):
prepay_hash = attr.ib(type=Optional[bytes], converter=hex_to_bytes)
privkey = attr.ib(type=bytes, converter=hex_to_bytes)
lockup_address = attr.ib(type=str)
receive_address = attr.ib(type=str)
claim_to_output = attr.ib(type=Optional[Tuple[str, int]]) # address, amount to claim the funding utxo to
funding_txid = attr.ib(type=Optional[str])
spending_txid = attr.ib(type=Optional[str])
is_redeemed = attr.ib(type=bool)
Expand Down Expand Up @@ -520,6 +521,9 @@ async def _claim_swap(self, swap: SwapData) -> None:
if spent_height is not None and spent_height > 0:
return
txin, locktime = self.create_claim_txin(txin=txin, swap=swap)
if swap.is_reverse and swap.claim_to_output:
asyncio.create_task(self._claim_to_output(swap, txin))
return
# note: there is no csv in the script, we just set this so that txbatcher waits for one confirmation
name = 'swap claim' if swap.is_reverse else 'swap refund'
can_be_batched = True
Expand All @@ -540,6 +544,42 @@ async def _claim_swap(self, swap: SwapData) -> None:
self.logger.info('got NoDynamicFeeEstimates')
return

async def _claim_to_output(self, swap: SwapData, claim_txin: PartialTxInput):
"""
Construct claim tx that spends exactly the funding utxo to the swap output, independent of the
current fee environment to guarantee the correct amount is being sent to the claim output which
might be an external address and to keep the claim transaction uncorrelated to the wallets utxos.
"""
assert swap.claim_to_output, swap
txout = PartialTxOutput.from_address_and_value(swap.claim_to_output[0], swap.claim_to_output[1])
tx = PartialTransaction.from_io([claim_txin], [txout])
can_be_broadcast = self.wallet.adb.get_tx_height(swap.funding_txid).height() > 0
already_broadcast = self.wallet.adb.get_tx_height(tx.txid()).height() >= 0
self.logger.debug(f"_claim_to_output: {can_be_broadcast=} {already_broadcast=}")

# add tx to db so it can be shown as future tx
if not self.wallet.adb.get_transaction(tx.txid()):
try:
self.wallet.adb.add_transaction(tx)
except Exception:
self.logger.exception("")
return
trigger_callback('wallet_updated', self)

# set or update future tx wanted height if it has not been broadcast yet
local_height = self.network.get_local_height()
wanted_height = local_height + claim_txin.get_block_based_relative_locktime()
if not already_broadcast and self.wallet.adb.future_tx.get(tx.txid(), 0) < wanted_height:
self.wallet.adb.set_future_tx(tx.txid(), wanted_height=wanted_height)

if can_be_broadcast and not already_broadcast:
tx = self.wallet.sign_transaction(tx, password=None, ignore_warnings=True)
assert tx and tx.is_complete(), tx
try:
await self.wallet.network.broadcast_transaction(tx)
except Exception:
self.logger.exception(f"cannot broadcast swap to output claim tx")

def get_fee_for_txbatcher(self):
return self._get_tx_fee(self.config.FEE_POLICY_SWAPS)

Expand Down Expand Up @@ -687,7 +727,6 @@ def add_normal_swap(
prepay_hash = None

lockup_address = script_to_p2wsh(redeem_script)
receive_address = self.wallet.get_receiving_address()
swap = SwapData(
redeem_script=redeem_script,
locktime=locktime,
Expand All @@ -696,7 +735,7 @@ def add_normal_swap(
prepay_hash=prepay_hash,
lockup_address=lockup_address,
onchain_amount=onchain_amount_sat,
receive_address=receive_address,
claim_to_output=None,
lightning_amount=lightning_amount_sat,
is_reverse=False,
is_redeemed=False,
Expand Down Expand Up @@ -749,12 +788,17 @@ def add_reverse_swap(
preimage: bytes,
payment_hash: bytes,
prepay_hash: Optional[bytes] = None,
claim_to_output: Optional[TxOutput] = None,
) -> SwapData:
if payment_hash.hex() in self._swaps:
raise Exception("payment_hash already in use")
assert sha256(preimage) == payment_hash
lockup_address = script_to_p2wsh(redeem_script)
receive_address = self.wallet.get_receiving_address()
if claim_to_output is not None:
# the claim_to_output value needs to be lower than the funding utxo value, otherwise
# there are no funds left for the fee of the claim tx
assert claim_to_output.value < onchain_amount_sat, f"{claim_to_output=} >= {onchain_amount_sat=}"
claim_to_output = (claim_to_output.address, claim_to_output.value)
swap = SwapData(
redeem_script=redeem_script,
locktime=locktime,
Expand All @@ -763,7 +807,7 @@ def add_reverse_swap(
prepay_hash=prepay_hash,
lockup_address=lockup_address,
onchain_amount=onchain_amount_sat,
receive_address=receive_address,
claim_to_output=claim_to_output,
lightning_amount=lightning_amount_sat,
is_reverse=True,
is_redeemed=False,
Expand Down Expand Up @@ -1014,6 +1058,7 @@ async def reverse_swap(
expected_onchain_amount_sat: int,
prepayment_sat: int,
channels: Optional[Sequence['Channel']] = None,
claim_to_output: Optional[TxOutput] = None,
) -> Optional[str]:
"""send on Lightning, receive on-chain

Expand Down Expand Up @@ -1116,15 +1161,17 @@ async def reverse_swap(
payment_hash=payment_hash,
prepay_hash=prepay_hash,
onchain_amount_sat=onchain_amount,
lightning_amount_sat=lightning_amount_sat)
lightning_amount_sat=lightning_amount_sat,
claim_to_output=claim_to_output,
)
# initiate fee payment.
if fee_invoice:
fee_invoice_obj = Invoice.from_bech32(fee_invoice)
asyncio.ensure_future(self.lnworker.pay_invoice(fee_invoice_obj))
# we return if we detect funding
async def wait_for_funding(swap):
while swap.funding_txid is None:
await asyncio.sleep(1)
await asyncio.sleep(0.1)
# initiate main payment
invoice_obj = Invoice.from_bech32(invoice)
tasks = [asyncio.create_task(self.lnworker.pay_invoice(invoice_obj, channels=channels)), asyncio.create_task(wait_for_funding(swap))]
Expand Down Expand Up @@ -1421,23 +1468,16 @@ def server_create_swap(self, request):
def get_groups_for_onchain_history(self):
current_height = self.wallet.adb.get_local_height()
d = {}
# add info about submarine swaps
settled_payments = self.lnworker.get_payments(status='settled')
with self.swaps_lock:
swaps_items = list(self._swaps.items())
for payment_hash_hex, swap in swaps_items:
txid = swap.spending_txid if swap.is_reverse else swap.funding_txid
if txid is None:
continue
payment_hash = bytes.fromhex(payment_hash_hex)
if payment_hash in settled_payments:
plist = settled_payments[payment_hash]
info = self.lnworker.get_payment_info(payment_hash)
direction, amount_msat, fee_msat, timestamp = self.lnworker.get_payment_value(info, plist)
else:
amount_msat = 0

if swap.is_reverse:
if swap.is_reverse and swap.claim_to_output:
group_label = 'Submarine Payment' + ' ' + self.config.format_amount_and_units(swap.claim_to_output[1])
elif swap.is_reverse:
group_label = 'Reverse swap' + ' ' + self.config.format_amount_and_units(swap.lightning_amount)
else:
group_label = 'Forward swap' + ' ' + self.config.format_amount_and_units(swap.onchain_amount)
Expand Down Expand Up @@ -1466,6 +1506,27 @@ def get_groups_for_onchain_history(self):
'label': _('Refund transaction'),
}
self.wallet._accounting_addresses.add(swap.lockup_address)
elif swap.is_reverse and swap.claim_to_output: # submarine payment
claim_tx = self.lnwatcher.adb.get_transaction(swap.spending_txid)
payee_spk = address_to_script(swap.claim_to_output[0])
if claim_tx and payee_spk not in (o.scriptpubkey for o in claim_tx.outputs()):
# the swapserver must have refunded itself as the claim_tx did not spend
# to the address we intended it to spend to, remove the funding
# address again from accounting addresses so the refund tx is not incorrectly
# shown in the wallet history as tx spending from this wallet
self.wallet._accounting_addresses.discard(swap.lockup_address)
# add the funding tx to the group as the total amount of the group would
# otherwise be ~2x the actual payment as the claim tx gets counted as negative
# value (as it sends from the wallet/accounting address balance)
d[swap.funding_txid] = {
'group_id': txid,
'label': _('Funding transaction'),
'group_label': group_label,
}
# add the lockup_address as the claim tx would otherwise not touch the wallet and
# wouldn't be shown in the history.
self.wallet._accounting_addresses.add(swap.lockup_address)

return d

def get_group_id_for_payment_hash(self, payment_hash: bytes) -> Optional[str]:
Expand Down Expand Up @@ -1659,6 +1720,7 @@ async def main_loop(self):
async def stop(self):
self.logger.info("shutting down nostr transport")
self.sm.is_initialized.clear()
self.is_connected.clear()
await self.taskgroup.cancel_remaining()
await self.relay_manager.close()
self.logger.info("nostr transport shut down")
Expand Down
14 changes: 13 additions & 1 deletion electrum/wallet_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ def __init__(self, wallet_db: 'WalletDB'):
# seed_version is now used for the version of the wallet file
OLD_SEED_VERSION = 4 # electrum versions < 2.0
NEW_SEED_VERSION = 11 # electrum versions >= 2.0
FINAL_SEED_VERSION = 61 # electrum >= 2.7 will set this to prevent
FINAL_SEED_VERSION = 62 # electrum >= 2.7 will set this to prevent
# old versions from overwriting new format


Expand Down Expand Up @@ -237,6 +237,7 @@ def upgrade(self):
self._convert_version_59()
self._convert_version_60()
self._convert_version_61()
self._convert_version_62()
self.put('seed_version', FINAL_SEED_VERSION) # just to be sure

def _convert_wallet_type(self):
Expand Down Expand Up @@ -1170,6 +1171,17 @@ def _convert_version_61(self):
lightning_payments[rhash] = new
self.data['seed_version'] = 61

def _convert_version_62(self):
if not self._is_upgrade_method_needed(61, 61):
return
swaps = self.data.get('submarine_swaps', {})
# remove unused receive_address field which is getting replaced by a claim_to_output field
# which also allows specifying an amount
for swap in swaps.values():
del swap['receive_address']
swap['claim_to_output'] = None
self.data['seed_version'] = 62

def _convert_imported(self):
if not self._is_upgrade_method_needed(0, 13):
return
Expand Down
2 changes: 1 addition & 1 deletion tests/test_txbatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ def is_connected(self):
prepay_hash=None,
privkey=bytes.fromhex('58fd0018a9a2737d1d6b81d380df96bf0c858473a9592015508a270a7c9b1d8d'),
lockup_address='tb1q2pvugjl4w56rqw4c7zg0q6mmmev0t5jjy3qzg7sl766phh9fxjxsrtl77t',
receive_address='tb1ql0adrj58g88xgz375yct63rclhv29hv03u0mel',
claim_to_output=None,
funding_txid='897eea7f53e917323e7472d7a2e3099173f7836c57f1b6850f5cbdfe8085dbf9',
spending_txid=None,
is_redeemed=False,
Expand Down