1
0
Files
electrum/electrum/submarine_swaps.py
ThomasV fd10ae3a3b New flow for submarine swaps:
- client requests payment_hash from the server
 - client sends an invoice with that hash
 - client waits to receive HTLCs, then broadcasts funding tx

This means that we now use same script for normal and reverse swaps.
The new flow is enabled by setting option LIGHTNING_SWAP_HTLC_FIRST
in the client. The old protocol is still supported server-side.
2023-08-10 09:04:30 +02:00

969 lines
41 KiB
Python

import asyncio
import json
import os
from typing import TYPE_CHECKING, Optional, Dict, Union
from decimal import Decimal
import math
import attr
import aiohttp
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
from .lnutil import REDEEM_AFTER_DOUBLE_SPENT_DELAY, ln_dummy_address
from .bitcoin import dust_threshold
from .logging import Logger
from .lnutil import hex_to_bytes
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
if TYPE_CHECKING:
from .network import Network
from .wallet import Abstract_Wallet
from .lnwatcher import LNWalletWatcher
from .lnworker import LNWallet
from .simple_config import SimpleConfig
CLAIM_FEE_SIZE = 136
LOCKUP_FEE_SIZE = 153 # assuming 1 output, 2 outputs
MIN_LOCKTIME_DELTA = 60
WITNESS_TEMPLATE_SWAP = [
opcodes.OP_HASH160,
OPPushDataGeneric(lambda x: x == 20),
opcodes.OP_EQUAL,
opcodes.OP_IF,
OPPushDataPubkey,
opcodes.OP_ELSE,
OPPushDataGeneric(None),
opcodes.OP_CHECKLOCKTIMEVERIFY,
opcodes.OP_DROP,
OPPushDataPubkey,
opcodes.OP_ENDIF,
opcodes.OP_CHECKSIG
]
# 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, lockup_address, payment_hash, locktime, *, refund_pubkey=None, claim_pubkey=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")
return parsed_script[7][1], parsed_script[13][1]
def check_normal_redeem_script(redeem_script, lockup_address, payment_hash, locktime, *, refund_pubkey=None, claim_pubkey=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_SWAP):
raise Exception("fswap check failed: scriptcode does not match template")
if script_to_p2wsh(redeem_script.hex()) != lockup_address:
raise Exception("fswap check failed: inconsistent scriptcode and address")
if ripemd(payment_hash) != parsed_script[1][1]:
raise Exception("fswap check failed: our preimage not in script")
if claim_pubkey and claim_pubkey != parsed_script[4][1]:
raise Exception("fswap check failed: our pubkey not in script")
if refund_pubkey and refund_pubkey != parsed_script[9][1]:
raise Exception("fswap check failed: our pubkey not in script")
if locktime != int.from_bytes(parsed_script[6][1], byteorder='little'):
raise Exception("fswap check failed: inconsistent locktime and script")
return parsed_script[4][1], parsed_script[9][1]
class SwapServerError(Exception):
def __str__(self):
return _("The swap server errored or is unreachable.")
@stored_in('submarine_swaps')
@attr.s
class SwapData(StoredObject):
is_reverse = attr.ib(type=bool)
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=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.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, swap in self.swaps.items():
swap._payment_hash = bytes.fromhex(payment_hash)
self._add_or_reindex_swap(swap)
if not swap.is_reverse and not swap.is_redeemed:
self.lnworker.register_callback_for_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.get_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)
coro = self.pay_pending_invoices()
asyncio.run_coroutine_threadsafe(network.taskgroup.spawn(coro), network.asyncio_loop)
async def pay_pending_invoices(self):
# FIXME this method can raise, which is not properly handled...?
self.invoices_to_pay = set()
while True:
await asyncio.sleep(1)
for key in list(self.invoices_to_pay):
swap = self.swaps.get(key)
if not swap:
continue
invoice = self.wallet.get_invoice(key)
if not invoice:
continue
current_height = self.network.get_local_height()
delta = swap.locktime - current_height
if delta <= MIN_LOCKTIME_DELTA:
# fixme: should consider cltv of ln payment
self.logger.info(f'locktime too close {key}')
continue
success, log = await self.lnworker.pay_invoice(invoice.lightning_invoice, attempts=1)
if not success:
self.logger.info(f'failed to pay invoice {key}')
continue
self.invoices_to_pay.remove(key)
@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()
delta = current_height - swap.locktime
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:
self.logger.info('amount too low, we should not reveal the preimage')
continue
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_conf = self.lnwatcher.adb.get_tx_height(txin.prevout.txid.hex()).conf
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 txin.block_height > 0 or self.wallet.config.LIGHTNING_ALLOW_INSTANT_SWAPS:
tx = self.lnwatcher.adb.get_transaction(txin.spent_txid)
self.logger.info(f'broadcasting tx {txin.spent_txid}')
await self.network.broadcast_transaction(tx)
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.logger.info(f'found confirmed refund')
payment_secret = self.lnworker.get_payment_secret(swap.payment_hash)
payment_key = swap.payment_hash + payment_secret
self.lnworker.fail_final_onion_forwarding(payment_key)
if delta < 0:
# too early for refund
continue
else:
if swap.preimage is None:
if funding_conf <= 0:
continue
preimage = self.lnworker.get_preimage(swap.payment_hash)
if preimage is None:
self.invoices_to_pay.add(swap.payment_hash.hex())
continue
swap.preimage = preimage
if self.network.config.TEST_SWAPSERVER_REFUND:
# for testing: do not create claim tx
continue
if spent_height is not None:
continue
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')
continue
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):
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):
# note: this assumes the keystore is not encrypted
key = payment_hash.hex()
if key in self.swaps:
swap = self.swaps[key]
if swap.funding_txid is None:
await self.broadcast_funding_tx(swap, None, None)
def create_normal_swap(self, *, lightning_amount_sat=None, payment_hash=None, their_pubkey=None):
""" server method """
locktime = self.network.get_local_height() + 140
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}
)
return 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,
their_pubkey=their_pubkey,
invoice=None,
prepay=True,
)
def add_normal_swap(self, *, redeem_script=None, locktime=None, onchain_amount_sat=None, lightning_amount_sat=None, payment_hash=None, our_privkey=None, their_pubkey=None, invoice=None, prepay=None):
""" if invoice is None, create 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
if not invoice:
_, invoice = self.lnworker.get_bolt11_invoice(
payment_hash=payment_hash,
amount_msat=invoice_amount_sat * 1000,
message='Submarine swap',
expiry=3600 * 24,
fallback_address=None,
channels=None,
)
# add payment info to lnworker
self.lnworker.add_payment_info_for_hold_invoice(payment_hash, invoice_amount_sat)
self.lnworker.register_callback_for_hold_invoice(payment_hash, self.hold_invoice_callback)
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=3600 * 24,
fallback_address=None,
channels=None,
)
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=None, payment_hash=None, their_pubkey=None):
""" server method. payment_hash is not None for old clients """
locktime = self.network.get_local_height() + 140
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)
#
if payment_hash is None:
preimage = os.urandom(32)
assert lightning_amount_sat is not None
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}
)
else:
# old client
preimage = None
redeem_script = construct_script(
WITNESS_TEMPLATE_SWAP,
{1:ripemd(payment_hash), 4:our_pubkey, 6:locktime, 9: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=None, locktime=None, privkey=None, lightning_amount_sat=None, onchain_amount_sat=None, preimage=None, payment_hash=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 = 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, pay_now=False):
invoice = Invoice.from_bech32(invoice)
key = invoice.rhash
payment_hash = bytes.fromhex(key)
assert key in self.swaps
self.wallet.save_invoice(invoice)
if pay_now:
# check that we have the preimage
swap = self.get_swap(payment_hash)
assert sha256(swap.preimage) == payment_hash
assert swap.spending_txid is None
self.invoices_to_pay.add(key)
async def normal_swap(
self,
*,
lightning_amount_sat: int,
expected_onchain_amount_sat: int,
password,
tx: PartialTransaction = None,
channels = None,
) -> str:
"""send on-chain BTC, receive on Lightning
- 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.
New flow:
- user requests swap
- server creates preimage, sends RHASH to user
- user creates hold invoice, sends it to server
"""
assert self.network
assert self.lnwatcher
amount_msat = lightning_amount_sat * 1000
refund_privkey = os.urandom(32)
refund_pubkey = ECPrivkey(refund_privkey).get_public_key_bytes(compressed=True)
if self.wallet.config.LIGHTNING_SWAP_HTLC_FIRST:
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"])
preimage = None
invoice = None
else:
# create invoice, send it to server
payment_hash = self.lnworker.create_payment_info(amount_msat=amount_msat)
preimage = self.lnworker.get_preimage(payment_hash)
_, invoice = self.lnworker.get_bolt11_invoice(
payment_hash=payment_hash,
amount_msat=amount_msat,
message='swap',
expiry=3600 * 24,
fallback_address=None,
channels=channels,
)
request_data = {
"type": "submarine",
"pairId": "BTC/BTC",
"orderSide": "sell",
"invoice": invoice,
"refundPublicKey": refund_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)
response_id = data["id"]
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
if self.wallet.config.LIGHTNING_SWAP_HTLC_FIRST:
claim_pubkey, _ = check_reverse_redeem_script(redeem_script, lockup_address, payment_hash, locktime, refund_pubkey=refund_pubkey)
else:
claim_pubkey, _ = check_normal_redeem_script(redeem_script, lockup_address, payment_hash, 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 more than a day
if locktime - self.network.get_local_height() >= 144:
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,
their_pubkey=claim_pubkey,
invoice=invoice,
prepay=False)
if self.wallet.config.LIGHTNING_SWAP_HTLC_FIRST:
# 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
while swap.funding_txid is None:
await asyncio.sleep(0.1)
else:
# broadcast funding tx right away
await self.broadcast_funding_tx(swap, tx, password)
return swap.funding_txid
@log_exceptions
async def broadcast_funding_tx(self, swap, tx, password):
# create funding tx
# note: rbf must not decrease payment
# this is taken care of in wallet._is_rbf_allowed_to_touch_tx_output
funding_output = PartialTxOutput.from_address_and_value(swap.lockup_address, swap.onchain_amount)
if tx is None:
tx = self.wallet.create_transaction(outputs=[funding_output], rbf=True, password=password)
else:
dummy_output = PartialTxOutput.from_address_and_value(ln_dummy_address(), swap.onchain_amount)
tx.outputs().remove(dummy_output)
tx.add_outputs([funding_output])
tx.set_rbf(True)
self.wallet.sign_transaction(tx, password)
await self.network.broadcast_transaction(tx)
swap.funding_txid = tx.txid()
async def reverse_swap(
self,
*,
lightning_amount_sat: int,
expected_onchain_amount_sat: int,
channels = None,
) -> bool:
"""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, lockup_address, payment_hash, 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, attempts=10))
# 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, attempts=10, 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']
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