You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

1082 lines
45 KiB

import asyncio
import json
import os
from typing import TYPE_CHECKING, Optional, Dict, Union, Sequence, Tuple
from decimal import Decimal
import math
import time
import attr
import aiohttp
from . import lnutil
from .crypto import sha256, hash_160
from .ecc import ECPrivkey
from .bitcoin import (script_to_p2wsh, opcodes, p2wsh_nested_script, push_script,
is_segwit_address, construct_witness)
from .transaction import PartialTxInput, PartialTxOutput, PartialTransaction, Transaction, TxInput, TxOutpoint
from .transaction import script_GetOp, match_script_against_template, OPPushDataGeneric, OPPushDataPubkey
from .util import log_exceptions, BelowDustLimit, OldTaskGroup
from .lnutil import REDEEM_AFTER_DOUBLE_SPENT_DELAY
from .bitcoin import dust_threshold, DummyAddress
from .logging import Logger
from .lnutil import hex_to_bytes
from .lnaddr import lndecode
from .json_db import StoredObject, stored_in
from . import constants
from .address_synchronizer import TX_HEIGHT_LOCAL
from .i18n import _
from .bitcoin import construct_script
from .crypto import ripemd
from .invoices import Invoice
from .network import TxBroadcastServerReturnedError
from .lnonion import OnionRoutingFailure, OnionFailureCode
if TYPE_CHECKING:
from .network import Network
from .wallet import Abstract_Wallet
from .lnwatcher import LNWalletWatcher
from .lnworker import LNWallet
from .lnchannel import Channel
from .simple_config import SimpleConfig
CLAIM_FEE_SIZE = 136
LOCKUP_FEE_SIZE = 153 # assuming 1 output, 2 outputs
MIN_LOCKTIME_DELTA = 60
LOCKTIME_DELTA_REFUND = 70
MAX_LOCKTIME_DELTA = 100
assert MIN_LOCKTIME_DELTA <= LOCKTIME_DELTA_REFUND <= MAX_LOCKTIME_DELTA
assert MAX_LOCKTIME_DELTA < lnutil.MIN_FINAL_CLTV_DELTA_ACCEPTED
assert MAX_LOCKTIME_DELTA < lnutil.MIN_FINAL_CLTV_DELTA_FOR_INVOICE
# The script of the reverse swaps has one extra check in it to verify
# that the length of the preimage is 32. This is required because in
# the reverse swaps the preimage is generated by the user and to
# settle the hold invoice, you need a preimage with 32 bytes . If that
# check wasn't there the user could generate a preimage with a
# different length which would still allow for claiming the onchain
# coins but the invoice couldn't be settled
WITNESS_TEMPLATE_REVERSE_SWAP = [
opcodes.OP_SIZE,
OPPushDataGeneric(None),
opcodes.OP_EQUAL,
opcodes.OP_IF,
opcodes.OP_HASH160,
OPPushDataGeneric(lambda x: x == 20),
opcodes.OP_EQUALVERIFY,
OPPushDataPubkey,
opcodes.OP_ELSE,
opcodes.OP_DROP,
OPPushDataGeneric(None),
opcodes.OP_CHECKLOCKTIMEVERIFY,
opcodes.OP_DROP,
OPPushDataPubkey,
opcodes.OP_ENDIF,
opcodes.OP_CHECKSIG
]
def check_reverse_redeem_script(
*,
redeem_script: str,
lockup_address: str,
payment_hash: bytes,
locktime: int,
refund_pubkey: bytes = None,
claim_pubkey: bytes = None,
) -> None:
redeem_script = bytes.fromhex(redeem_script)
parsed_script = [x for x in script_GetOp(redeem_script)]
if not match_script_against_template(redeem_script, WITNESS_TEMPLATE_REVERSE_SWAP):
raise Exception("rswap check failed: scriptcode does not match template")
if script_to_p2wsh(redeem_script.hex()) != lockup_address:
raise Exception("rswap check failed: inconsistent scriptcode and address")
if ripemd(payment_hash) != parsed_script[5][1]:
raise Exception("rswap check failed: our preimage not in script")
if claim_pubkey and claim_pubkey != parsed_script[7][1]:
raise Exception("rswap check failed: our pubkey not in script")
if refund_pubkey and refund_pubkey != parsed_script[13][1]:
raise Exception("rswap check failed: our pubkey not in script")
if locktime != int.from_bytes(parsed_script[10][1], byteorder='little'):
raise Exception("rswap check failed: inconsistent locktime and script")
class SwapServerError(Exception):
def __str__(self):
return _("The swap server errored or is unreachable.")
def now():
return int(time.time())
@stored_in('submarine_swaps')
@attr.s
class SwapData(StoredObject):
is_reverse = attr.ib(type=bool) # for whoever is running code (PoV of client or server)
locktime = attr.ib(type=int)
onchain_amount = attr.ib(type=int) # in sats
lightning_amount = attr.ib(type=int) # in sats
redeem_script = attr.ib(type=bytes, converter=hex_to_bytes)
preimage = 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)
lockup_address = attr.ib(type=str)
receive_address = attr.ib(type=str)
funding_txid = attr.ib(type=Optional[str])
spending_txid = attr.ib(type=Optional[str])
is_redeemed = attr.ib(type=bool)
_funding_prevout = None # type: Optional[TxOutpoint] # for RBF
_payment_hash = None
@property
def payment_hash(self) -> bytes:
return self._payment_hash
def create_claim_tx(
*,
txin: PartialTxInput,
witness_script: bytes,
address: str,
amount_sat: int,
locktime: int,
) -> PartialTransaction:
"""Create tx to either claim successful reverse-swap,
or to get refunded for timed-out forward-swap.
"""
txin.script_sig = b''
txin.witness_script = witness_script
txout = PartialTxOutput.from_address_and_value(address, amount_sat)
tx = PartialTransaction.from_io([txin], [txout], version=2, locktime=locktime)
tx.set_rbf(True)
return tx
class SwapManager(Logger):
network: Optional['Network'] = None
lnwatcher: Optional['LNWalletWatcher'] = None
def __init__(self, *, wallet: 'Abstract_Wallet', lnworker: 'LNWallet'):
Logger.__init__(self)
self.normal_fee = 0
self.lockup_fee = 0
self.claim_fee = 0 # part of the boltz prococol, not used by Electrum
self.percentage = 0
self._min_amount = None
self._max_amount = None
self.wallet = wallet
self.lnworker = lnworker
self.taskgroup = None
self.dummy_address = DummyAddress.SWAP
self.swaps = self.wallet.db.get_dict('submarine_swaps') # type: Dict[str, SwapData]
self._swaps_by_funding_outpoint = {} # type: Dict[TxOutpoint, SwapData]
self._swaps_by_lockup_address = {} # type: Dict[str, SwapData]
for payment_hash_hex, swap in self.swaps.items():
payment_hash = bytes.fromhex(payment_hash_hex)
swap._payment_hash = payment_hash
self._add_or_reindex_swap(swap)
if not swap.is_reverse and not swap.is_redeemed:
self.lnworker.register_hold_invoice(payment_hash, self.hold_invoice_callback)
self.prepayments = {} # type: Dict[bytes, bytes] # fee_rhash -> rhash
for k, swap in self.swaps.items():
if swap.prepay_hash is not None:
self.prepayments[swap.prepay_hash] = bytes.fromhex(k)
# api url
self.api_url = wallet.config.SWAPSERVER_URL
# init default min & max
self.init_min_max_values()
def start_network(self, *, network: 'Network', lnwatcher: 'LNWalletWatcher'):
assert network
assert lnwatcher
assert self.network is None, "already started"
self.network = network
self.lnwatcher = lnwatcher
for k, swap in self.swaps.items():
if swap.is_redeemed:
continue
self.add_lnwatcher_callback(swap)
self.taskgroup = OldTaskGroup()
asyncio.run_coroutine_threadsafe(self.main_loop(), self.network.asyncio_loop)
async def main_loop(self):
self.logger.info("starting taskgroup.")
try:
async with self.taskgroup as group:
await group.spawn(self.pay_pending_invoices())
except Exception as e:
self.logger.exception("taskgroup died.")
finally:
self.logger.info("taskgroup stopped.")
async def stop(self):
await self.taskgroup.cancel_remaining()
async def pay_invoice(self, key):
self.logger.info(f'trying to pay invoice {key}')
self.invoices_to_pay[key] = 1000000000000 # lock
try:
invoice = self.wallet.get_invoice(key)
success, log = await self.lnworker.pay_invoice(invoice.lightning_invoice, attempts=10)
except Exception as e:
self.logger.info(f'exception paying {key}, will not retry')
self.invoices_to_pay.pop(key, None)
return
if not success:
self.logger.info(f'failed to pay {key}, will retry in 10 minutes')
self.invoices_to_pay[key] = now() + 600
else:
self.logger.info(f'paid invoice {key}')
self.invoices_to_pay.pop(key, None)
async def pay_pending_invoices(self):
self.invoices_to_pay = {}
while True:
await asyncio.sleep(5)
for key, not_before in list(self.invoices_to_pay.items()):
if now() < not_before:
continue
await self.taskgroup.spawn(self.pay_invoice(key))
def cancel_normal_swap(self, swap: SwapData):
""" we must not have broadcast the funding tx """
if swap.funding_txid is not None:
self.logger.info(f'cannot cancel swap {swap.payment_hash.hex()}: already funded')
return
self._fail_swap(swap, 'user cancelled')
def _fail_swap(self, swap: SwapData, reason: str):
self.logger.info(f'failing swap {swap.payment_hash.hex()}: {reason}')
if not swap.is_reverse and swap.payment_hash in self.lnworker.hold_invoice_callbacks:
self.lnworker.unregister_hold_invoice(swap.payment_hash)
payment_secret = self.lnworker.get_payment_secret(swap.payment_hash)
payment_key = swap.payment_hash + payment_secret
e = OnionRoutingFailure(code=OnionFailureCode.UNKNOWN_NEXT_PEER, data=b'')
self.lnworker.save_forwarding_failure(payment_key.hex(), failure_message=e)
self.lnwatcher.remove_callback(swap.lockup_address)
if swap.funding_txid is None:
self.swaps.pop(swap.payment_hash.hex())
@log_exceptions
async def _claim_swap(self, swap: SwapData) -> None:
assert self.network
assert self.lnwatcher
if not self.lnwatcher.adb.is_up_to_date():
return
current_height = self.network.get_local_height()
remaining_time = swap.locktime - current_height
txos = self.lnwatcher.adb.get_addr_outputs(swap.lockup_address)
for txin in txos.values():
if swap.is_reverse and txin.value_sats() < swap.onchain_amount:
# amount too low, we must not reveal the preimage
continue
break
else:
# swap not funded.
txin = None
# if it is a normal swap, we might have double spent the funding tx
# in that case we need to fail the HTLCs
if remaining_time <= 0:
self._fail_swap(swap, 'expired')
if txin:
# the swap is funded
swap.funding_txid = txin.prevout.txid.hex()
swap._funding_prevout = txin.prevout
self._add_or_reindex_swap(swap) # to update _swaps_by_funding_outpoint
funding_height = self.lnwatcher.adb.get_tx_height(txin.prevout.txid.hex())
spent_height = txin.spent_height
if spent_height is not None:
swap.spending_txid = txin.spent_txid
if spent_height > 0:
if current_height - spent_height > REDEEM_AFTER_DOUBLE_SPENT_DELAY:
self.logger.info(f'stop watching swap {swap.lockup_address}')
self.lnwatcher.remove_callback(swap.lockup_address)
swap.is_redeemed = True
elif spent_height == TX_HEIGHT_LOCAL:
if funding_height.conf > 0 or (swap.is_reverse and self.wallet.config.LIGHTNING_ALLOW_INSTANT_SWAPS):
tx = self.lnwatcher.adb.get_transaction(txin.spent_txid)
try:
await self.network.broadcast_transaction(tx)
except TxBroadcastServerReturnedError:
self.logger.info(f'error broadcasting claim tx {txin.spent_txid}')
elif funding_height.height == TX_HEIGHT_LOCAL:
# the funding tx was double spent.
# this will remove both funding and child (spending tx) from adb
self.lnwatcher.adb.remove_transaction(swap.funding_txid)
swap.funding_txid = None
swap.spending_txid = None
else:
# spending tx is in mempool
pass
if not swap.is_reverse:
if swap.preimage is None and spent_height is not None:
# extract the preimage, add it to lnwatcher
tx = self.lnwatcher.adb.get_transaction(txin.spent_txid)
preimage = tx.inputs()[0].witness_elements()[1]
if sha256(preimage) == swap.payment_hash:
swap.preimage = preimage
self.logger.info(f'found preimage: {preimage.hex()}')
self.lnworker.preimages[swap.payment_hash.hex()] = preimage.hex()
# note: we must check the payment secret before we broadcast the funding tx
else:
# refund tx
if spent_height > 0:
self._fail_swap(swap, 'refund tx confirmed')
return
if remaining_time > 0:
# too early for refund
return
else:
if swap.preimage is None:
swap.preimage = self.lnworker.get_preimage(swap.payment_hash)
if swap.preimage is None:
if funding_height.conf <= 0:
return
key = swap.payment_hash.hex()
if remaining_time <= MIN_LOCKTIME_DELTA:
if key in self.invoices_to_pay:
# fixme: should consider cltv of ln payment
self.logger.info(f'locktime too close {key} {remaining_time}')
self.invoices_to_pay.pop(key, None)
return
if key not in self.invoices_to_pay:
self.invoices_to_pay[key] = 0
return
if self.network.config.TEST_SWAPSERVER_REFUND:
# for testing: do not create claim tx
return
if spent_height is not None:
return
try:
tx = self._create_and_sign_claim_tx(txin=txin, swap=swap, config=self.wallet.config)
except BelowDustLimit:
self.logger.info('utxo value below dust threshold')
return
self.logger.info(f'adding claim tx {tx.txid()}')
self.wallet.adb.add_transaction(tx)
swap.spending_txid = tx.txid()
def get_claim_fee(self):
return self.get_fee(CLAIM_FEE_SIZE)
def get_fee(self, size):
# note: 'size' is in vbytes
return self._get_fee(size=size, config=self.wallet.config)
@classmethod
def _get_fee(cls, *, size, config: 'SimpleConfig'):
return config.estimate_fee(size, allow_fallback_to_static_rates=True)
def get_swap(self, payment_hash: bytes) -> Optional[SwapData]:
# for history
swap = self.swaps.get(payment_hash.hex())
if swap:
return swap
payment_hash = self.prepayments.get(payment_hash)
if payment_hash:
return self.swaps.get(payment_hash.hex())
def add_lnwatcher_callback(self, swap: SwapData) -> None:
callback = lambda: self._claim_swap(swap)
self.lnwatcher.add_callback(swap.lockup_address, callback)
async def hold_invoice_callback(self, payment_hash: bytes) -> None:
# note: this assumes the wallet has been unlocked
key = payment_hash.hex()
if key in self.swaps:
swap = self.swaps[key]
if swap.funding_txid is None:
password = self.wallet.get_unlocked_password()
for batch_rbf in [True, False]:
tx = self.create_funding_tx(swap, None, password=password, batch_rbf=batch_rbf)
try:
await self.broadcast_funding_tx(swap, tx)
except TxBroadcastServerReturnedError:
continue
break
def create_normal_swap(self, *, lightning_amount_sat: int, payment_hash: bytes, their_pubkey: bytes = None):
""" server method """
assert lightning_amount_sat
locktime = self.network.get_local_height() + LOCKTIME_DELTA_REFUND
our_privkey = os.urandom(32)
our_pubkey = ECPrivkey(our_privkey).get_public_key_bytes(compressed=True)
onchain_amount_sat = self._get_recv_amount(lightning_amount_sat, is_reverse=True) # what the client is going to receive
redeem_script = construct_script(
WITNESS_TEMPLATE_REVERSE_SWAP,
{1:32, 5:ripemd(payment_hash), 7:their_pubkey, 10:locktime, 13:our_pubkey}
)
swap, invoice, prepay_invoice = self.add_normal_swap(
redeem_script=redeem_script,
locktime=locktime,
onchain_amount_sat=onchain_amount_sat,
lightning_amount_sat=lightning_amount_sat,
payment_hash=payment_hash,
our_privkey=our_privkey,
prepay=True,
)
self.lnworker.register_hold_invoice(payment_hash, self.hold_invoice_callback)
return swap, invoice, prepay_invoice
def add_normal_swap(
self, *,
redeem_script: str,
locktime: int, # onchain
onchain_amount_sat: int,
lightning_amount_sat: int,
payment_hash: bytes,
our_privkey: bytes,
prepay: bool,
channels: Optional[Sequence['Channel']] = None,
) -> Tuple[SwapData, str, str]:
"""creates a hold invoice"""
if prepay:
prepay_amount_sat = self.get_claim_fee() * 2
invoice_amount_sat = lightning_amount_sat - prepay_amount_sat
else:
invoice_amount_sat = lightning_amount_sat
_, invoice = self.lnworker.get_bolt11_invoice(
payment_hash=payment_hash,
amount_msat=invoice_amount_sat * 1000,
message='Submarine swap',
expiry=300,
fallback_address=None,
channels=channels,
)
# add payment info to lnworker
self.lnworker.add_payment_info_for_hold_invoice(payment_hash, invoice_amount_sat)
if prepay:
prepay_hash = self.lnworker.create_payment_info(amount_msat=prepay_amount_sat*1000)
_, prepay_invoice = self.lnworker.get_bolt11_invoice(
payment_hash=prepay_hash,
amount_msat=prepay_amount_sat * 1000,
message='Submarine swap mining fees',
expiry=300,
fallback_address=None,
channels=channels,
)
self.lnworker.bundle_payments([payment_hash, prepay_hash])
self.prepayments[prepay_hash] = payment_hash
else:
prepay_invoice = None
prepay_hash = None
lockup_address = script_to_p2wsh(redeem_script)
receive_address = self.wallet.get_receiving_address()
swap = SwapData(
redeem_script = bytes.fromhex(redeem_script),
locktime = locktime,
privkey = our_privkey,
preimage = None,
prepay_hash = prepay_hash,
lockup_address = lockup_address,
onchain_amount = onchain_amount_sat,
receive_address = receive_address,
lightning_amount = lightning_amount_sat,
is_reverse = False,
is_redeemed = False,
funding_txid = None,
spending_txid = None,
)
swap._payment_hash = payment_hash
self._add_or_reindex_swap(swap)
self.add_lnwatcher_callback(swap)
return swap, invoice, prepay_invoice
def create_reverse_swap(self, *, lightning_amount_sat: int, their_pubkey: bytes) -> SwapData:
""" server method. """
assert lightning_amount_sat is not None
locktime = self.network.get_local_height() + LOCKTIME_DELTA_REFUND
privkey = os.urandom(32)
our_pubkey = ECPrivkey(privkey).get_public_key_bytes(compressed=True)
onchain_amount_sat = self._get_send_amount(lightning_amount_sat, is_reverse=False)
preimage = os.urandom(32)
payment_hash = sha256(preimage)
redeem_script = construct_script(
WITNESS_TEMPLATE_REVERSE_SWAP,
{1:32, 5:ripemd(payment_hash), 7:our_pubkey, 10:locktime, 13:their_pubkey}
)
swap = self.add_reverse_swap(
redeem_script=redeem_script,
locktime=locktime,
privkey=privkey,
preimage=preimage,
payment_hash=payment_hash,
prepay_hash=None,
onchain_amount_sat=onchain_amount_sat,
lightning_amount_sat=lightning_amount_sat)
return swap
def add_reverse_swap(
self,
*,
redeem_script: str,
locktime: int, # onchain
privkey: bytes,
lightning_amount_sat: int,
onchain_amount_sat: int,
preimage: bytes,
payment_hash: bytes,
prepay_hash: Optional[bytes] = None,
) -> SwapData:
lockup_address = script_to_p2wsh(redeem_script)
receive_address = self.wallet.get_receiving_address()
swap = SwapData(
redeem_script = bytes.fromhex(redeem_script),
locktime = locktime,
privkey = privkey,
preimage = preimage,
prepay_hash = prepay_hash,
lockup_address = lockup_address,
onchain_amount = onchain_amount_sat,
receive_address = receive_address,
lightning_amount = lightning_amount_sat,
is_reverse = True,
is_redeemed = False,
funding_txid = None,
spending_txid = None,
)
if prepay_hash:
self.prepayments[prepay_hash] = payment_hash
swap._payment_hash = payment_hash
self._add_or_reindex_swap(swap)
self.add_lnwatcher_callback(swap)
return swap
def add_invoice(self, invoice: str, pay_now: bool = False) -> None:
invoice = Invoice.from_bech32(invoice)
key = invoice.rhash
payment_hash = bytes.fromhex(key)
assert key in self.swaps
swap = self.swaps[key]
assert swap.lightning_amount == int(invoice.get_amount_sat())
self.wallet.save_invoice(invoice)
if pay_now:
# check that we have the preimage
assert sha256(swap.preimage) == payment_hash
assert swap.spending_txid is None
self.invoices_to_pay[key] = 0
async def normal_swap(
self,
*,
lightning_amount_sat: int,
expected_onchain_amount_sat: int,
password,
tx: PartialTransaction = None,
channels = None,
) -> Optional[str]:
"""send on-chain BTC, receive on Lightning
Old (removed) flow:
- User generates an LN invoice with RHASH, and knows preimage.
- User creates on-chain output locked to RHASH.
- Server pays LN invoice. User reveals preimage.
- Server spends the on-chain output using preimage.
cltv safety requirement: (onchain_locktime > LN_locktime), otherwise server is vulnerable
New flow:
- User requests swap
- Server creates preimage, sends RHASH to user
- User creates hold invoice, sends it to server
- Server sends HTLC, user holds it
- User creates on-chain output locked to RHASH
- Server spends the on-chain output using preimage (revealing the preimage)
- User fulfills HTLC using preimage
cltv safety requirement: (onchain_locktime < LN_locktime), otherwise client is vulnerable
"""
assert self.network
assert self.lnwatcher
swap, invoice = await self.request_normal_swap(
lightning_amount_sat=lightning_amount_sat,
expected_onchain_amount_sat=expected_onchain_amount_sat,
channels=channels,
)
tx = self.create_funding_tx(swap, tx, password=password)
return await self.wait_for_htlcs_and_broadcast(swap=swap, invoice=invoice, tx=tx)
async def request_normal_swap(
self,
*,
lightning_amount_sat: int,
expected_onchain_amount_sat: int,
channels: Optional[Sequence['Channel']] = None,
) -> Tuple[SwapData, str]:
refund_privkey = os.urandom(32)
refund_pubkey = ECPrivkey(refund_privkey).get_public_key_bytes(compressed=True)
self.logger.info('requesting preimage hash for swap')
request_data = {
"invoiceAmount": lightning_amount_sat,
"refundPublicKey": refund_pubkey.hex()
}
response = await self.network.async_send_http_on_proxy(
'post',
self.api_url + '/createnormalswap',
json=request_data,
timeout=30)
data = json.loads(response)
payment_hash = bytes.fromhex(data["preimageHash"])
zeroconf = data["acceptZeroConf"]
onchain_amount = data["expectedAmount"]
locktime = data["timeoutBlockHeight"]
lockup_address = data["address"]
redeem_script = data["redeemScript"]
# verify redeem_script is built with our pubkey and preimage
check_reverse_redeem_script(
redeem_script=redeem_script,
lockup_address=lockup_address,
payment_hash=payment_hash,
locktime=locktime,
refund_pubkey=refund_pubkey,
)
# check that onchain_amount is not more than what we estimated
if onchain_amount > expected_onchain_amount_sat:
raise Exception(f"fswap check failed: onchain_amount is more than what we estimated: "
f"{onchain_amount} > {expected_onchain_amount_sat}")
# verify that they are not locking up funds for too long
if locktime - self.network.get_local_height() > MAX_LOCKTIME_DELTA:
raise Exception("fswap check failed: locktime too far in future")
swap, invoice, _ = self.add_normal_swap(
redeem_script=redeem_script,
locktime=locktime,
lightning_amount_sat=lightning_amount_sat,
onchain_amount_sat=onchain_amount,
payment_hash=payment_hash,
our_privkey=refund_privkey,
prepay=False,
channels=channels,
)
return swap, invoice
async def wait_for_htlcs_and_broadcast(
self,
*,
swap: SwapData,
invoice: str,
tx: Transaction,
) -> Optional[str]:
payment_hash = swap.payment_hash
refund_pubkey = ECPrivkey(swap.privkey).get_public_key_bytes(compressed=True)
async def callback(payment_hash):
await self.broadcast_funding_tx(swap, tx)
self.lnworker.register_hold_invoice(payment_hash, callback)
# send invoice to server and wait for htlcs
request_data = {
"preimageHash": payment_hash.hex(),
"invoice": invoice,
"refundPublicKey": refund_pubkey.hex(),
}
response = await self.network.async_send_http_on_proxy(
'post',
self.api_url + '/addswapinvoice',
json=request_data,
timeout=30)
data = json.loads(response)
# wait for funding tx
lnaddr = lndecode(invoice)
while swap.funding_txid is None and not lnaddr.is_expired():
await asyncio.sleep(0.1)
return swap.funding_txid
def create_funding_tx(
self,
swap: SwapData,
tx: Optional[PartialTransaction],
*,
password,
batch_rbf: Optional[bool] = None,
) -> PartialTransaction:
# create funding tx
# note: rbf must not decrease payment
# this is taken care of in wallet._is_rbf_allowed_to_touch_tx_output
if tx is None:
funding_output = PartialTxOutput.from_address_and_value(swap.lockup_address, swap.onchain_amount)
tx = self.wallet.create_transaction(
outputs=[funding_output],
rbf=True,
password=password,
batch_rbf=batch_rbf,
)
else:
tx.replace_output_address(DummyAddress.SWAP, swap.lockup_address)
tx.set_rbf(True)
self.wallet.sign_transaction(tx, password)
return tx
@log_exceptions
async def request_swap_for_tx(self, tx: 'PartialTransaction') -> Optional[Tuple[SwapData, str, PartialTransaction]]:
for o in tx.outputs():
if o.address == self.dummy_address:
change_amount = o.value
break
else:
return
await self.get_pairs()
lightning_amount_sat = self.get_recv_amount(change_amount, is_reverse=False)
swap, invoice = await self.request_normal_swap(
lightning_amount_sat = lightning_amount_sat,
expected_onchain_amount_sat=change_amount)
tx.replace_output_address(DummyAddress.SWAP, swap.lockup_address)
return swap, invoice, tx
@log_exceptions
async def broadcast_funding_tx(self, swap: SwapData, tx: Transaction) -> None:
swap.funding_txid = tx.txid()
await self.network.broadcast_transaction(tx)
async def reverse_swap(
self,
*,
lightning_amount_sat: int,
expected_onchain_amount_sat: int,
channels: Optional[Sequence['Channel']] = None,
) -> Optional[str]:
"""send on Lightning, receive on-chain
- User generates preimage, RHASH. Sends RHASH to server.
- Server creates an LN invoice for RHASH.
- User pays LN invoice - except server needs to hold the HTLC as preimage is unknown.
- Server creates on-chain output locked to RHASH.
- User spends on-chain output, revealing preimage.
- Server fulfills HTLC using preimage.
Note: expected_onchain_amount_sat is BEFORE deducting the on-chain claim tx fee.
"""
assert self.network
assert self.lnwatcher
privkey = os.urandom(32)
our_pubkey = ECPrivkey(privkey).get_public_key_bytes(compressed=True)
preimage = os.urandom(32)
payment_hash = sha256(preimage)
request_data = {
"type": "reversesubmarine",
"pairId": "BTC/BTC",
"orderSide": "buy",
"invoiceAmount": lightning_amount_sat,
"preimageHash": payment_hash.hex(),
"claimPublicKey": our_pubkey.hex()
}
response = await self.network.async_send_http_on_proxy(
'post',
self.api_url + '/createswap',
json=request_data,
timeout=30)
data = json.loads(response)
invoice = data['invoice']
fee_invoice = data.get('minerFeeInvoice')
lockup_address = data['lockupAddress']
redeem_script = data['redeemScript']
locktime = data['timeoutBlockHeight']
onchain_amount = data["onchainAmount"]
response_id = data['id']
# verify redeem_script is built with our pubkey and preimage
check_reverse_redeem_script(
redeem_script=redeem_script,
lockup_address=lockup_address,
payment_hash=payment_hash,
locktime=locktime,
refund_pubkey=None,
claim_pubkey=our_pubkey,
)
# check that the onchain amount is what we expected
if onchain_amount < expected_onchain_amount_sat:
raise Exception(f"rswap check failed: onchain_amount is less than what we expected: "
f"{onchain_amount} < {expected_onchain_amount_sat}")
# verify that we will have enough time to get our tx confirmed
if locktime - self.network.get_local_height() <= MIN_LOCKTIME_DELTA:
raise Exception("rswap check failed: locktime too close")
# verify invoice payment_hash
lnaddr = self.lnworker._check_invoice(invoice)
invoice_amount = int(lnaddr.get_amount_sat())
if lnaddr.paymenthash != payment_hash:
raise Exception("rswap check failed: inconsistent RHASH and invoice")
# check that the lightning amount is what we requested
if fee_invoice:
fee_lnaddr = self.lnworker._check_invoice(fee_invoice)
invoice_amount += fee_lnaddr.get_amount_sat()
prepay_hash = fee_lnaddr.paymenthash
else:
prepay_hash = None
if int(invoice_amount) != lightning_amount_sat:
raise Exception(f"rswap check failed: invoice_amount ({invoice_amount}) "
f"not what we requested ({lightning_amount_sat})")
# save swap data to wallet file
swap = self.add_reverse_swap(
redeem_script=redeem_script,
locktime=locktime,
privkey=privkey,
preimage=preimage,
payment_hash=payment_hash,
prepay_hash=prepay_hash,
onchain_amount_sat=onchain_amount,
lightning_amount_sat=lightning_amount_sat)
# initiate fee payment.
if fee_invoice:
asyncio.ensure_future(self.lnworker.pay_invoice(fee_invoice))
# we return if we detect funding
async def wait_for_funding(swap):
while swap.funding_txid is None:
await asyncio.sleep(1)
# initiate main payment
tasks = [asyncio.create_task(self.lnworker.pay_invoice(invoice, channels=channels)), asyncio.create_task(wait_for_funding(swap))]
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
return swap.funding_txid
def _add_or_reindex_swap(self, swap: SwapData) -> None:
if swap.payment_hash.hex() not in self.swaps:
self.swaps[swap.payment_hash.hex()] = swap
if swap._funding_prevout:
self._swaps_by_funding_outpoint[swap._funding_prevout] = swap
self._swaps_by_lockup_address[swap.lockup_address] = swap
def init_pairs(self) -> None:
""" for server """
self.percentage = 0.5
self._min_amount = 20000
self._max_amount = 10000000
self.normal_fee = self.get_fee(CLAIM_FEE_SIZE)
self.lockup_fee = self.get_fee(LOCKUP_FEE_SIZE)
self.claim_fee = self.get_fee(CLAIM_FEE_SIZE)
async def get_pairs(self) -> None:
"""Might raise SwapServerError."""
from .network import Network
try:
response = await Network.async_send_http_on_proxy(
'get',
self.api_url + '/getpairs',
timeout=30)
except aiohttp.ClientError as e:
self.logger.error(f"Swap server errored: {e!r}")
raise SwapServerError() from e
# we assume server response is well-formed; otherwise let an exception propagate to the crash reporter
pairs = json.loads(response)
# cache data to disk
with open(self.pairs_filename(), 'w', encoding='utf-8') as f:
f.write(json.dumps(pairs))
fees = pairs['pairs']['BTC/BTC']['fees']
self.percentage = fees['percentage']
self.normal_fee = fees['minerFees']['baseAsset']['normal']
self.lockup_fee = fees['minerFees']['baseAsset']['reverse']['lockup']
self.claim_fee = fees['minerFees']['baseAsset']['reverse']['claim']
limits = pairs['pairs']['BTC/BTC']['limits']
self._min_amount = limits['minimal']
self._max_amount = limits['maximal']
assert pairs.get('htlcFirst') is True
def pairs_filename(self):
return os.path.join(self.wallet.config.path, 'swap_pairs')
def init_min_max_values(self):
# use default values if we never requested pairs
try:
with open(self.pairs_filename(), 'r', encoding='utf-8') as f:
pairs = json.loads(f.read())
limits = pairs['pairs']['BTC/BTC']['limits']
self._min_amount = limits['minimal']
self._max_amount = limits['maximal']
except Exception:
self._min_amount = 10000
self._max_amount = 10000000
def get_max_amount(self):
return self._max_amount
def get_min_amount(self):
return self._min_amount
def check_invoice_amount(self, x):
return x >= self.get_min_amount() and x <= self.get_max_amount()
def _get_recv_amount(self, send_amount: Optional[int], *, is_reverse: bool) -> Optional[int]:
"""For a given swap direction and amount we send, returns how much we will receive.
Note: in the reverse direction, the mining fee for the on-chain claim tx is NOT accounted for.
In the reverse direction, the result matches what the swap server returns as response["onchainAmount"].
"""
if send_amount is None:
return
x = Decimal(send_amount)
percentage = Decimal(self.percentage)
if is_reverse:
if not self.check_invoice_amount(x):
return
# see/ref:
# https://github.com/BoltzExchange/boltz-backend/blob/e7e2d30f42a5bea3665b164feb85f84c64d86658/lib/service/Service.ts#L948
percentage_fee = math.ceil(percentage * x / 100)
base_fee = self.lockup_fee
x -= percentage_fee + base_fee
x = math.floor(x)
if x < dust_threshold():
return
else:
x -= self.normal_fee
percentage_fee = math.ceil(x * percentage / (100 + percentage))
x -= percentage_fee
if not self.check_invoice_amount(x):
return
x = int(x)
return x
def _get_send_amount(self, recv_amount: Optional[int], *, is_reverse: bool) -> Optional[int]:
"""For a given swap direction and amount we want to receive, returns how much we will need to send.
Note: in the reverse direction, the mining fee for the on-chain claim tx is NOT accounted for.
In the forward direction, the result matches what the swap server returns as response["expectedAmount"].
"""
if not recv_amount:
return
x = Decimal(recv_amount)
percentage = Decimal(self.percentage)
if is_reverse:
# see/ref:
# https://github.com/BoltzExchange/boltz-backend/blob/e7e2d30f42a5bea3665b164feb85f84c64d86658/lib/service/Service.ts#L928
# https://github.com/BoltzExchange/boltz-backend/blob/e7e2d30f42a5bea3665b164feb85f84c64d86658/lib/service/Service.ts#L958
base_fee = self.lockup_fee
x += base_fee
x = math.ceil(x / ((100 - percentage) / 100))
if not self.check_invoice_amount(x):
return
else:
if not self.check_invoice_amount(x):
return
# see/ref:
# https://github.com/BoltzExchange/boltz-backend/blob/e7e2d30f42a5bea3665b164feb85f84c64d86658/lib/service/Service.ts#L708
# https://github.com/BoltzExchange/boltz-backend/blob/e7e2d30f42a5bea3665b164feb85f84c64d86658/lib/rates/FeeProvider.ts#L90
percentage_fee = math.ceil(percentage * x / 100)
x += percentage_fee + self.normal_fee
x = int(x)
return x
def get_recv_amount(self, send_amount: Optional[int], *, is_reverse: bool) -> Optional[int]:
# first, add percentage fee
recv_amount = self._get_recv_amount(send_amount, is_reverse=is_reverse)
# sanity check calculation can be inverted
if recv_amount is not None:
inverted_send_amount = self._get_send_amount(recv_amount, is_reverse=is_reverse)
# accept off-by ones as amt_rcv = recv_amt(send_amt(amt_rcv)) only up to +-1
if abs(send_amount - inverted_send_amount) > 1:
raise Exception(f"calc-invert-sanity-check failed. is_reverse={is_reverse}. "
f"send_amount={send_amount} -> recv_amount={recv_amount} -> inverted_send_amount={inverted_send_amount}")
# second, add on-chain claim tx fee
if is_reverse and recv_amount is not None:
recv_amount -= self.get_claim_fee()
return recv_amount
def get_send_amount(self, recv_amount: Optional[int], *, is_reverse: bool) -> Optional[int]:
# first, add on-chain claim tx fee
if is_reverse and recv_amount is not None:
recv_amount += self.get_claim_fee()
# second, add percentage fee
send_amount = self._get_send_amount(recv_amount, is_reverse=is_reverse)
# sanity check calculation can be inverted
if send_amount is not None:
inverted_recv_amount = self._get_recv_amount(send_amount, is_reverse=is_reverse)
if recv_amount != inverted_recv_amount:
raise Exception(f"calc-invert-sanity-check failed. is_reverse={is_reverse}. "
f"recv_amount={recv_amount} -> send_amount={send_amount} -> inverted_recv_amount={inverted_recv_amount}")
return send_amount
def get_swap_by_funding_tx(self, tx: Transaction) -> Optional[SwapData]:
if len(tx.outputs()) != 1:
return False
prevout = TxOutpoint(txid=bytes.fromhex(tx.txid()), out_idx=0)
return self._swaps_by_funding_outpoint.get(prevout)
def get_swap_by_claim_tx(self, tx: Transaction) -> Optional[SwapData]:
txin = tx.inputs()[0]
return self.get_swap_by_claim_txin(txin)
def get_swap_by_claim_txin(self, txin: TxInput) -> Optional[SwapData]:
return self._swaps_by_funding_outpoint.get(txin.prevout)
def is_lockup_address_for_a_swap(self, addr: str) -> bool:
return bool(self._swaps_by_lockup_address.get(addr))
def add_txin_info(self, txin: PartialTxInput) -> None:
"""Add some info to a claim txin.
note: even without signing, this is useful for tx size estimation.
"""
swap = self.get_swap_by_claim_txin(txin)
if not swap:
return
preimage = swap.preimage if swap.is_reverse else 0
witness_script = swap.redeem_script
txin.script_sig = b''
txin.witness_script = witness_script
sig_dummy = b'\x00' * 71 # DER-encoded ECDSA sig, with low S and low R
witness = [sig_dummy, preimage, witness_script]
txin.witness_sizehint = len(bytes.fromhex(construct_witness(witness)))
@classmethod
def sign_tx(cls, tx: PartialTransaction, swap: SwapData) -> None:
preimage = swap.preimage if swap.is_reverse else 0
witness_script = swap.redeem_script
txin = tx.inputs()[0]
assert len(tx.inputs()) == 1, f"expected 1 input for swap claim tx. found {len(tx.inputs())}"
assert txin.prevout.txid.hex() == swap.funding_txid
txin.script_sig = b''
txin.witness_script = witness_script
sig = bytes.fromhex(tx.sign_txin(0, swap.privkey))
witness = [sig, preimage, witness_script]
txin.witness = bytes.fromhex(construct_witness(witness))
@classmethod
def _create_and_sign_claim_tx(
cls,
*,
txin: PartialTxInput,
swap: SwapData,
config: 'SimpleConfig',
) -> PartialTransaction:
# FIXME the mining fee should depend on swap.is_reverse.
# the txs are not the same size...
amount_sat = txin.value_sats() - cls._get_fee(size=CLAIM_FEE_SIZE, config=config)
if amount_sat < dust_threshold():
raise BelowDustLimit()
if swap.is_reverse: # successful reverse swap
locktime = 0
# preimage will be set in sign_tx
else: # timing out forward swap
locktime = swap.locktime
tx = create_claim_tx(
txin=txin,
witness_script=swap.redeem_script,
address=swap.receive_address,
amount_sat=amount_sat,
locktime=locktime,
)
cls.sign_tx(tx, swap)
return tx
def max_amount_forward_swap(self) -> Optional[int]:
""" returns None if we cannot swap """
max_swap_amt_ln = self.get_max_amount()
max_recv_amt_ln = int(self.lnworker.num_sats_can_receive())
max_amt_ln = int(min(max_swap_amt_ln, max_recv_amt_ln))
max_amt_oc = self.get_send_amount(max_amt_ln, is_reverse=False) or 0
min_amt_oc = self.get_send_amount(self.get_min_amount(), is_reverse=False) or 0
return max_amt_oc if max_amt_oc >= min_amt_oc else None