import asyncio
import json
import os
from typing import TYPE_CHECKING, Optional, Dict, Union, Sequence, Tuple, Iterable
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
MIN_FINAL_CLTV_DELTA_FOR_CLIENT = 3 * 144  # note: put in invoice, but is not enforced by receiver in lnpeer.py
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
assert MAX_LOCKTIME_DELTA < MIN_FINAL_CLTV_DELTA_FOR_CLIENT


# 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.INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS, 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
            # note: swap.funding_txid can change due to RBF, it will get updated here:
            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,
            min_final_cltv_expiry_delta: Optional[int] = None,
    ) -> Tuple[SwapData, str, Optional[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,
            min_final_cltv_expiry_delta=min_final_cltv_expiry_delta,
        )
        # 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,
                min_final_cltv_expiry_delta=min_final_cltv_expiry_delta,
            )
            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,
            # When the client is doing a normal swap, we create a ln-invoice with larger than usual final_cltv_delta.
            # If the user goes offline after broadcasting the funding tx (but before it is mined and
            # the server claims it), they need to come back online before the held ln-htlc expires (see #8940).
            # If the held ln-htlc expires, and the funding tx got confirmed, the server will have claimed the onchain
            # funds, and the ln-htlc will be timed out onchain (and channel force-closed). i.e. the user loses the swap
            # amount. Increasing the final_cltv_delta the user puts in the invoice extends this critical window.
            min_final_cltv_expiry_delta=MIN_FINAL_CLTV_DELTA_FOR_CLIENT,
        )
        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.
            - if the server requested a fee prepayment (using 'minerFeeInvoice'),
              the server will have the preimage for that. The user will send HTLCs for both the main RHASH,
              and for the fee prepayment. Once both MPP sets arrive at the server, the server will fulfill
              the HTLCs for the fee prepayment (before creating the on-chain output).
        - 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_swaps_by_funding_tx(self, tx: Transaction) -> Iterable[SwapData]:
        swaps = []
        for txout_idx, _txo in enumerate(tx.outputs()):
            prevout = TxOutpoint(txid=bytes.fromhex(tx.txid()), out_idx=txout_idx)
            if swap := self._swaps_by_funding_outpoint.get(prevout):
                swaps.append(swap)
        return swaps

    def get_swap_by_claim_tx(self, tx: Transaction) -> Optional[SwapData]:
        # note: we don't batch claim txs atm (batch_rbf cannot combine them
        #       as the inputs do not belong to the wallet)
        if not (len(tx.inputs()) == 1 and len(tx.outputs()) == 1):
            return None
        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
