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:
@@ -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]:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user