1
0

swaps: allow reverse swaps to external address

Implement logic to claim a reverse swap funding output to any given
address. This allows to do onchain payments to external recipients
through a submarine swap.
This commit is contained in:
f321x
2025-11-06 16:00:22 +01:00
parent d7bc617034
commit a0455f8382
3 changed files with 94 additions and 21 deletions

View File

@@ -24,11 +24,12 @@ from collections import defaultdict
from .i18n import _ from .i18n import _
from .logging import Logger from .logging import Logger
from .crypto import sha256, ripemd 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 . import bitcoin
from .transaction import ( from .transaction import (
PartialTxInput, PartialTxOutput, PartialTransaction, Transaction, TxInput, TxOutpoint, script_GetOp, PartialTxInput, PartialTxOutput, PartialTransaction, Transaction, TxInput, TxOutpoint, script_GetOp,
match_script_against_template, OPPushDataGeneric, OPPushDataPubkey match_script_against_template, OPPushDataGeneric, OPPushDataPubkey, TxOutput,
) )
from .util import ( from .util import (
log_exceptions, ignore_exceptions, BelowDustLimit, OldTaskGroup, ca_path, gen_nostr_ann_pow, 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) prepay_hash = attr.ib(type=Optional[bytes], converter=hex_to_bytes)
privkey = attr.ib(type=bytes, converter=hex_to_bytes) privkey = attr.ib(type=bytes, converter=hex_to_bytes)
lockup_address = attr.ib(type=str) 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]) funding_txid = attr.ib(type=Optional[str])
spending_txid = attr.ib(type=Optional[str]) spending_txid = attr.ib(type=Optional[str])
is_redeemed = attr.ib(type=bool) is_redeemed = attr.ib(type=bool)
@@ -520,6 +521,9 @@ class SwapManager(Logger):
if spent_height is not None and spent_height > 0: if spent_height is not None and spent_height > 0:
return return
txin, locktime = self.create_claim_txin(txin=txin, swap=swap) 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 # 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' name = 'swap claim' if swap.is_reverse else 'swap refund'
can_be_batched = True can_be_batched = True
@@ -540,6 +544,42 @@ class SwapManager(Logger):
self.logger.info('got NoDynamicFeeEstimates') self.logger.info('got NoDynamicFeeEstimates')
return 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): def get_fee_for_txbatcher(self):
return self._get_tx_fee(self.config.FEE_POLICY_SWAPS) return self._get_tx_fee(self.config.FEE_POLICY_SWAPS)
@@ -687,7 +727,6 @@ class SwapManager(Logger):
prepay_hash = None prepay_hash = None
lockup_address = script_to_p2wsh(redeem_script) lockup_address = script_to_p2wsh(redeem_script)
receive_address = self.wallet.get_receiving_address()
swap = SwapData( swap = SwapData(
redeem_script=redeem_script, redeem_script=redeem_script,
locktime=locktime, locktime=locktime,
@@ -696,7 +735,7 @@ class SwapManager(Logger):
prepay_hash=prepay_hash, prepay_hash=prepay_hash,
lockup_address=lockup_address, lockup_address=lockup_address,
onchain_amount=onchain_amount_sat, onchain_amount=onchain_amount_sat,
receive_address=receive_address, claim_to_output=None,
lightning_amount=lightning_amount_sat, lightning_amount=lightning_amount_sat,
is_reverse=False, is_reverse=False,
is_redeemed=False, is_redeemed=False,
@@ -749,12 +788,17 @@ class SwapManager(Logger):
preimage: bytes, preimage: bytes,
payment_hash: bytes, payment_hash: bytes,
prepay_hash: Optional[bytes] = None, prepay_hash: Optional[bytes] = None,
claim_to_output: Optional[TxOutput] = None,
) -> SwapData: ) -> SwapData:
if payment_hash.hex() in self._swaps: if payment_hash.hex() in self._swaps:
raise Exception("payment_hash already in use") raise Exception("payment_hash already in use")
assert sha256(preimage) == payment_hash assert sha256(preimage) == payment_hash
lockup_address = script_to_p2wsh(redeem_script) 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( swap = SwapData(
redeem_script=redeem_script, redeem_script=redeem_script,
locktime=locktime, locktime=locktime,
@@ -763,7 +807,7 @@ class SwapManager(Logger):
prepay_hash=prepay_hash, prepay_hash=prepay_hash,
lockup_address=lockup_address, lockup_address=lockup_address,
onchain_amount=onchain_amount_sat, onchain_amount=onchain_amount_sat,
receive_address=receive_address, claim_to_output=claim_to_output,
lightning_amount=lightning_amount_sat, lightning_amount=lightning_amount_sat,
is_reverse=True, is_reverse=True,
is_redeemed=False, is_redeemed=False,
@@ -1014,6 +1058,7 @@ class SwapManager(Logger):
expected_onchain_amount_sat: int, expected_onchain_amount_sat: int,
prepayment_sat: int, prepayment_sat: int,
channels: Optional[Sequence['Channel']] = None, channels: Optional[Sequence['Channel']] = None,
claim_to_output: Optional[TxOutput] = None,
) -> Optional[str]: ) -> Optional[str]:
"""send on Lightning, receive on-chain """send on Lightning, receive on-chain
@@ -1116,7 +1161,9 @@ class SwapManager(Logger):
payment_hash=payment_hash, payment_hash=payment_hash,
prepay_hash=prepay_hash, prepay_hash=prepay_hash,
onchain_amount_sat=onchain_amount, 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. # initiate fee payment.
if fee_invoice: if fee_invoice:
fee_invoice_obj = Invoice.from_bech32(fee_invoice) fee_invoice_obj = Invoice.from_bech32(fee_invoice)
@@ -1124,7 +1171,7 @@ class SwapManager(Logger):
# we return if we detect funding # we return if we detect funding
async def wait_for_funding(swap): async def wait_for_funding(swap):
while swap.funding_txid is None: while swap.funding_txid is None:
await asyncio.sleep(1) await asyncio.sleep(0.1)
# initiate main payment # initiate main payment
invoice_obj = Invoice.from_bech32(invoice) 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))] 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): def get_groups_for_onchain_history(self):
current_height = self.wallet.adb.get_local_height() current_height = self.wallet.adb.get_local_height()
d = {} d = {}
# add info about submarine swaps
settled_payments = self.lnworker.get_payments(status='settled')
with self.swaps_lock: with self.swaps_lock:
swaps_items = list(self._swaps.items()) swaps_items = list(self._swaps.items())
for payment_hash_hex, swap in swaps_items: for payment_hash_hex, swap in swaps_items:
txid = swap.spending_txid if swap.is_reverse else swap.funding_txid txid = swap.spending_txid if swap.is_reverse else swap.funding_txid
if txid is None: if txid is None:
continue 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) group_label = 'Reverse swap' + ' ' + self.config.format_amount_and_units(swap.lightning_amount)
else: else:
group_label = 'Forward swap' + ' ' + self.config.format_amount_and_units(swap.onchain_amount) group_label = 'Forward swap' + ' ' + self.config.format_amount_and_units(swap.onchain_amount)
@@ -1466,6 +1506,27 @@ class SwapManager(Logger):
'label': _('Refund transaction'), 'label': _('Refund transaction'),
} }
self.wallet._accounting_addresses.add(swap.lockup_address) 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 return d
def get_group_id_for_payment_hash(self, payment_hash: bytes) -> Optional[str]: def get_group_id_for_payment_hash(self, payment_hash: bytes) -> Optional[str]:

View File

@@ -73,7 +73,7 @@ class WalletUnfinished(WalletFileException):
# seed_version is now used for the version of the wallet file # seed_version is now used for the version of the wallet file
OLD_SEED_VERSION = 4 # electrum versions < 2.0 OLD_SEED_VERSION = 4 # electrum versions < 2.0
NEW_SEED_VERSION = 11 # 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 # old versions from overwriting new format
@@ -237,6 +237,7 @@ class WalletDBUpgrader(Logger):
self._convert_version_59() self._convert_version_59()
self._convert_version_60() self._convert_version_60()
self._convert_version_61() self._convert_version_61()
self._convert_version_62()
self.put('seed_version', FINAL_SEED_VERSION) # just to be sure self.put('seed_version', FINAL_SEED_VERSION) # just to be sure
def _convert_wallet_type(self): def _convert_wallet_type(self):
@@ -1170,6 +1171,17 @@ class WalletDBUpgrader(Logger):
lightning_payments[rhash] = new lightning_payments[rhash] = new
self.data['seed_version'] = 61 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): def _convert_imported(self):
if not self._is_upgrade_method_needed(0, 13): if not self._is_upgrade_method_needed(0, 13):
return return

View File

@@ -69,7 +69,7 @@ SWAPDATA = SwapData(
prepay_hash=None, prepay_hash=None,
privkey=bytes.fromhex('58fd0018a9a2737d1d6b81d380df96bf0c858473a9592015508a270a7c9b1d8d'), privkey=bytes.fromhex('58fd0018a9a2737d1d6b81d380df96bf0c858473a9592015508a270a7c9b1d8d'),
lockup_address='tb1q2pvugjl4w56rqw4c7zg0q6mmmev0t5jjy3qzg7sl766phh9fxjxsrtl77t', lockup_address='tb1q2pvugjl4w56rqw4c7zg0q6mmmev0t5jjy3qzg7sl766phh9fxjxsrtl77t',
receive_address='tb1ql0adrj58g88xgz375yct63rclhv29hv03u0mel', claim_to_output=None,
funding_txid='897eea7f53e917323e7472d7a2e3099173f7836c57f1b6850f5cbdfe8085dbf9', funding_txid='897eea7f53e917323e7472d7a2e3099173f7836c57f1b6850f5cbdfe8085dbf9',
spending_txid=None, spending_txid=None,
is_redeemed=False, is_redeemed=False,