diff --git a/electrum/submarine_swaps.py b/electrum/submarine_swaps.py index dfc5ebfe9..6fb5229a4 100644 --- a/electrum/submarine_swaps.py +++ b/electrum/submarine_swaps.py @@ -24,11 +24,12 @@ from collections import defaultdict 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, @@ -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) @@ -520,6 +521,9 @@ class SwapManager(Logger): 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 @@ -540,6 +544,42 @@ class SwapManager(Logger): 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) @@ -687,7 +727,6 @@ class SwapManager(Logger): 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, @@ -696,7 +735,7 @@ class SwapManager(Logger): 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, @@ -749,12 +788,17 @@ class SwapManager(Logger): 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, @@ -763,7 +807,7 @@ class SwapManager(Logger): 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, @@ -1014,6 +1058,7 @@ class SwapManager(Logger): 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 @@ -1116,7 +1161,9 @@ class SwapManager(Logger): 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) @@ -1124,7 +1171,7 @@ class SwapManager(Logger): # 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))] @@ -1421,23 +1468,16 @@ class SwapManager(Logger): 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) @@ -1466,6 +1506,27 @@ class SwapManager(Logger): '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]: diff --git a/electrum/wallet_db.py b/electrum/wallet_db.py index 586afb42e..09cf9d948 100644 --- a/electrum/wallet_db.py +++ b/electrum/wallet_db.py @@ -73,7 +73,7 @@ class WalletUnfinished(WalletFileException): # 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 @@ -237,6 +237,7 @@ class WalletDBUpgrader(Logger): 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): @@ -1170,6 +1171,17 @@ class WalletDBUpgrader(Logger): 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 diff --git a/tests/test_txbatcher.py b/tests/test_txbatcher.py index f038bb7e5..86dd5fbbf 100644 --- a/tests/test_txbatcher.py +++ b/tests/test_txbatcher.py @@ -69,7 +69,7 @@ SWAPDATA = SwapData( 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,