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 .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]:

View File

@@ -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

View File

@@ -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,