electrum

Electrum Bitcoin wallet
git clone https://git.parazyd.org/electrum
Log | Files | Refs | Submodules

commit 66817d41f9a61c56775fe1c2f7538ad5ed5c965b
parent d740475e7aabd4c4762ab7b07bc8d43d289df5fb
Author: SomberNight <somber.night@protonmail.com>
Date:   Mon, 16 Jul 2018 17:01:18 +0200

lnwatcher improvements

- only try sweeping csv-locked to_local if past timelock
- check if outputs are already spent
- no need to keep watching channels for which all outputs are spent and mined deep

Diffstat:
Melectrum/address_synchronizer.py | 1-
Melectrum/lnwatcher.py | 303++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
2 files changed, 192 insertions(+), 112 deletions(-)

diff --git a/electrum/address_synchronizer.py b/electrum/address_synchronizer.py @@ -355,7 +355,6 @@ class AddressSynchronizer(Logger): # Store fees self.db.update_tx_fees(tx_fees) - @profiler def load_local_history(self): self._history_local = {} # address -> set(txid) diff --git a/electrum/lnwatcher.py b/electrum/lnwatcher.py @@ -1,3 +1,5 @@ +import threading + from .util import PrintError, bh2u, bfh, NoDynamicFeeEstimates from .lnutil import (funding_output_script, extract_ctn_from_tx, derive_privkey, get_per_commitment_secret_from_seed, derive_pubkey, @@ -8,6 +10,10 @@ from .bitcoin import redeem_script_to_address, TYPE_ADDRESS from . import transaction from .transaction import Transaction from . import ecc +from . import wallet + +TX_MINED_STATUS_DEEP, TX_MINED_STATUS_SHALLOW, TX_MINED_STATUS_MEMPOOL, TX_MINED_STATUS_FREE = range(0, 4) + class LNWatcher(PrintError): @@ -51,13 +57,32 @@ def funding_address_for_channel(chan): class LNChanCloseHandler(PrintError): + # TODO if verifier gets an incorrect merkle proof, that tx will never verify!! + # similarly, what if server ignores request for merkle proof? + # maybe we should disconnect from server in these cases def __init__(self, network, wallet, chan): self.network = network self.wallet = wallet self.chan = chan + self.lock = threading.Lock() self.funding_address = funding_address_for_channel(chan) - network.request_address_history(self.funding_address, self.on_history) + self.watched_addresses = set() + network.register_callback(self.on_network_update, ['updated']) + self.watch_address(self.funding_address) + + def on_network_update(self, event, *args): + if self.wallet.synchronizer.is_up_to_date(): + self.check_onchain_situation() + + def stop_and_delete(self): + self.network.unregister_callback(self.on_network_update) + # TODO delete channel from wallet storage? + + def watch_address(self, addr): + with self.lock: + self.watched_addresses.add(addr) + self.wallet.synchronizer.add(addr) # TODO: de-duplicate? def parse_response(self, response): @@ -66,46 +91,38 @@ class LNChanCloseHandler(PrintError): return None, None return response['params'], response['result'] - def on_history(self, response): - params, result = self.parse_response(response) - if not params: - return - addr = params[0] - if self.funding_address != addr: - self.print_error("unexpected funding address: {} != {}" - .format(self.funding_address, addr)) - return - txids = set(map(lambda item: item['tx_hash'], result)) - self.network.get_transactions(txids, self.on_tx_response) - - def on_tx_response(self, response): - params, result = self.parse_response(response) - if not params: - return - tx_hash = params[0] - tx = Transaction(result) - try: - tx.deserialize() - except Exception: - self.print_msg("cannot deserialize transaction", tx_hash) + def check_onchain_situation(self): + funding_outpoint = self.chan.funding_outpoint + ctx_candidate_txid = self.wallet.spent_outpoints[funding_outpoint.txid].get(funding_outpoint.output_index) + if ctx_candidate_txid is None: return - if tx_hash != tx.txid(): - self.print_error("received tx does not match expected txid ({} != {})" - .format(tx_hash, tx.txid())) + ctx_candidate = self.wallet.transactions.get(ctx_candidate_txid) + if ctx_candidate is None: return - funding_outpoint = self.chan.funding_outpoint - for i, txin in enumerate(tx.inputs()): - if txin['prevout_hash'] == funding_outpoint.txid \ - and txin['prevout_n'] == funding_outpoint.output_index: - self.print_error("funding outpoint {} is spent by {}" - .format(funding_outpoint, tx_hash)) - self.inspect_spending_tx(tx, i) + #self.print_error("funding outpoint {} is spent by {}" + # .format(funding_outpoint, ctx_candidate_txid)) + for i, txin in enumerate(ctx_candidate.inputs()): + if txin['type'] == 'coinbase': continue + prevout_hash = txin['prevout_hash'] + prevout_n = txin['prevout_n'] + if prevout_hash == funding_outpoint.txid and prevout_n == funding_outpoint.output_index: break + else: + raise Exception('{} is supposed to be spent by {}, but none of the inputs spend it' + .format(funding_outpoint, ctx_candidate_txid)) + height, conf, timestamp = self.wallet.get_tx_height(ctx_candidate_txid) + if conf == 0: + return + keep_watching_this = self.inspect_ctx_candidate(ctx_candidate, i) + if not keep_watching_this: + self.stop_and_delete() # TODO batch sweeps # TODO sweep HTLC outputs - # TODO implement nursery that waits for timelocks - def inspect_spending_tx(self, ctx, txin_idx: int): + def inspect_ctx_candidate(self, ctx, txin_idx: int): + """Returns True iff found any not-deeply-spent outputs that we could + potentially sweep at some point.""" + keep_watching_this = False chan = self.chan ctn = extract_ctn_from_tx(ctx, txin_idx, chan.local_config.payment_basepoint.pubkey, @@ -119,7 +136,7 @@ class LNChanCloseHandler(PrintError): # note that we might also get here if this is our ctx and the ctn just happens to match their_cur_pcp = chan.remote_state.current_per_commitment_point if their_cur_pcp is not None: - self.find_and_sweep_their_ctx_to_remote(ctx, their_cur_pcp) + keep_watching_this |= self.find_and_sweep_their_ctx_to_remote(ctx, their_cur_pcp) # see if we have a revoked secret for this ctn ("breach") try: per_commitment_secret = chan.remote_state.revocation_store.retrieve_secret( @@ -130,15 +147,36 @@ class LNChanCloseHandler(PrintError): # note that we might also get here if this is our ctx and we just happen to have # the secret for the symmetric ctn their_pcp = ecc.ECPrivkey(per_commitment_secret).get_public_key_bytes(compressed=True) - self.find_and_sweep_their_ctx_to_remote(ctx, their_pcp) - self.find_and_sweep_their_ctx_to_local(ctx, per_commitment_secret) + keep_watching_this |= self.find_and_sweep_their_ctx_to_remote(ctx, their_pcp) + keep_watching_this |= self.find_and_sweep_their_ctx_to_local(ctx, per_commitment_secret) # see if it's our ctx our_per_commitment_secret = get_per_commitment_secret_from_seed( chan.local_state.per_commitment_secret_seed, RevocationStore.START_INDEX - ctn) our_per_commitment_point = ecc.ECPrivkey(our_per_commitment_secret).get_public_key_bytes(compressed=True) - self.find_and_sweep_our_ctx_to_local(ctx, our_per_commitment_point) + keep_watching_this |= self.find_and_sweep_our_ctx_to_local(ctx, our_per_commitment_point) + return keep_watching_this + + def get_tx_mined_status(self, txid): + if not txid: + return TX_MINED_STATUS_FREE + height, conf, timestamp = self.wallet.get_tx_height(txid) + if conf > 100: + return TX_MINED_STATUS_DEEP + elif conf > 0: + return TX_MINED_STATUS_SHALLOW + elif height in (wallet.TX_HEIGHT_UNCONFIRMED, wallet.TX_HEIGHT_UNCONF_PARENT): + return TX_MINED_STATUS_MEMPOOL + elif height == wallet.TX_HEIGHT_LOCAL: + return TX_MINED_STATUS_FREE + elif height > 0 and conf == 0: + # unverified but claimed to be mined + return TX_MINED_STATUS_MEMPOOL + else: + raise NotImplementedError() def find_and_sweep_their_ctx_to_remote(self, ctx, their_pcp: bytes): + """Returns True iff found a not-deeply-spent output that we could + potentially sweep at some point.""" payment_bp_privkey = ecc.ECPrivkey(self.chan.local_config.payment_basepoint.privkey) our_payment_privkey = derive_privkey(payment_bp_privkey.secret_scalar, their_pcp) our_payment_privkey = ecc.ECPrivkey.from_secret_scalar(our_payment_privkey) @@ -151,40 +189,26 @@ class LNChanCloseHandler(PrintError): #self.print_error("ctx {} is normal unilateral close by them".format(ctx.txid())) break else: - return - sweep_tx = self.create_sweeptx_their_ctx_to_remote(ctx, output_idx, our_payment_privkey) + return False + if to_remote_address not in self.watched_addresses: + self.watch_address(to_remote_address) + return True + spending_txid = self.wallet.spent_outpoints[ctx.txid()].get(output_idx) + stx_mined_status = self.get_tx_mined_status(spending_txid) + if stx_mined_status == TX_MINED_STATUS_DEEP: + return False + elif stx_mined_status in (TX_MINED_STATUS_SHALLOW, TX_MINED_STATUS_MEMPOOL): + return True + sweep_tx = create_sweeptx_their_ctx_to_remote(self.network, self.wallet, ctx, + output_idx, our_payment_privkey) self.network.broadcast_transaction(sweep_tx, lambda res: self.print_tx_broadcast_result('sweep_their_ctx_to_remote', res)) + return True - def create_sweeptx_their_ctx_to_remote(self, ctx, output_idx: int, our_payment_privkey: ecc.ECPrivkey): - our_payment_pubkey = our_payment_privkey.get_public_key_hex(compressed=True) - val = ctx.outputs()[output_idx][2] - sweep_inputs = [{ - 'type': 'p2wpkh', - 'x_pubkeys': [our_payment_pubkey], - 'num_sig': 1, - 'prevout_n': output_idx, - 'prevout_hash': ctx.txid(), - 'value': val, - 'coinbase': False, - 'signatures': [None], - }] - tx_size_bytes = 110 # approx size of p2wpkh->p2wpkh - try: - fee = self.network.config.estimate_fee(tx_size_bytes) - except NoDynamicFeeEstimates: - fee_per_kb = self.network.config.fee_per_kb(dyn=False) - fee = self.network.config.estimate_fee_for_feerate(fee_per_kb, tx_size_bytes) - sweep_outputs = [(TYPE_ADDRESS, self.wallet.get_receiving_address(), val-fee)] - locktime = self.network.get_local_height() - sweep_tx = Transaction.from_io(sweep_inputs, sweep_outputs, locktime=locktime) - sweep_tx.set_rbf(True) - sweep_tx.sign({our_payment_pubkey: (our_payment_privkey.get_secret_bytes(), True)}) - if not sweep_tx.is_complete(): - raise Exception('channel close sweep tx is not complete') - return sweep_tx def find_and_sweep_their_ctx_to_local(self, ctx, per_commitment_secret: bytes): + """Returns True iff found a not-deeply-spent output that we could + potentially sweep at some point.""" per_commitment_point = ecc.ECPrivkey(per_commitment_secret).get_public_key_bytes(compressed=True) revocation_privkey = lnutil.derive_blinded_privkey(self.chan.local_config.revocation_basepoint.privkey, per_commitment_secret) @@ -202,12 +226,25 @@ class LNChanCloseHandler(PrintError): break else: self.print_error('could not find to_local output in their ctx {}'.format(ctx.txid())) - return - sweep_tx = self.create_sweeptx_ctx_to_local(ctx, output_idx, witness_script, revocation_privkey, True) + return False + if to_local_address not in self.watched_addresses: + self.watch_address(to_local_address) + return True + spending_txid = self.wallet.spent_outpoints[ctx.txid()].get(output_idx) + stx_mined_status = self.get_tx_mined_status(spending_txid) + if stx_mined_status == TX_MINED_STATUS_DEEP: + return False + elif stx_mined_status in (TX_MINED_STATUS_SHALLOW, TX_MINED_STATUS_MEMPOOL): + return True + sweep_tx = create_sweeptx_ctx_to_local(self.network, self.wallet, ctx, output_idx, + witness_script, revocation_privkey, True) self.network.broadcast_transaction(sweep_tx, lambda res: self.print_tx_broadcast_result('sweep_their_ctx_to_local', res)) + return True def find_and_sweep_our_ctx_to_local(self, ctx, our_pcp: bytes): + """Returns True iff found a not-deeply-spent output that we could + potentially sweep at some point.""" delayed_bp_privkey = ecc.ECPrivkey(self.chan.local_config.delayed_basepoint.privkey) our_localdelayed_privkey = derive_privkey(delayed_bp_privkey.secret_scalar, our_pcp) our_localdelayed_privkey = ecc.ECPrivkey.from_secret_scalar(our_localdelayed_privkey) @@ -225,49 +262,27 @@ class LNChanCloseHandler(PrintError): break else: self.print_error('could not find to_local output in our ctx {}'.format(ctx.txid())) - return - # TODO if the CSV lock is still pending, this will fail - sweep_tx = self.create_sweeptx_ctx_to_local(ctx, output_idx, witness_script, - our_localdelayed_privkey.get_secret_bytes(), - False, to_self_delay) + return False + if to_local_address not in self.watched_addresses: + self.watch_address(to_local_address) + return True + spending_txid = self.wallet.spent_outpoints[ctx.txid()].get(output_idx) + stx_mined_status = self.get_tx_mined_status(spending_txid) + if stx_mined_status == TX_MINED_STATUS_DEEP: + return False + elif stx_mined_status in (TX_MINED_STATUS_SHALLOW, TX_MINED_STATUS_MEMPOOL): + return True + # check timelock + ctx_num_conf = self.wallet.get_tx_height(ctx.txid())[1] + if to_self_delay > ctx_num_conf: + self.print_error('waiting for CSV ({} < {}) for ctx {}'.format(ctx_num_conf, to_self_delay, ctx.txid())) + return True + sweep_tx = create_sweeptx_ctx_to_local(self.network, self.wallet, ctx, output_idx, + witness_script, our_localdelayed_privkey.get_secret_bytes(), + False, to_self_delay) self.network.broadcast_transaction(sweep_tx, lambda res: self.print_tx_broadcast_result('sweep_our_ctx_to_local', res)) - - def create_sweeptx_ctx_to_local(self, ctx, output_idx: int, witness_script: str, - privkey: bytes, is_revocation: bool, to_self_delay: int=None): - """Create a txn that sweeps the 'to_local' output of a commitment - transaction into our wallet. - - privkey: either revocation_privkey or localdelayed_privkey - is_revocation: tells us which ^ - """ - val = ctx.outputs()[output_idx][2] - sweep_inputs = [{ - 'scriptSig': '', - 'type': 'p2wsh', - 'signatures': [], - 'num_sig': 0, - 'prevout_n': output_idx, - 'prevout_hash': ctx.txid(), - 'value': val, - 'coinbase': False, - 'preimage_script': witness_script, - }] - if to_self_delay is not None: - sweep_inputs[0]['sequence'] = to_self_delay - tx_size_bytes = 121 # approx size of to_local -> p2wpkh - try: - fee = self.network.config.estimate_fee(tx_size_bytes) - except NoDynamicFeeEstimates: - fee_per_kb = self.network.config.fee_per_kb(dyn=False) - fee = self.network.config.estimate_fee_for_feerate(fee_per_kb, tx_size_bytes) - sweep_outputs = [(TYPE_ADDRESS, self.wallet.get_receiving_address(), val - fee)] - locktime = self.network.get_local_height() - sweep_tx = Transaction.from_io(sweep_inputs, sweep_outputs, locktime=locktime, version=2) - sig = sweep_tx.sign_txin(0, privkey) - witness = transaction.construct_witness([sig, int(is_revocation), witness_script]) - sweep_tx.inputs()[0]['witness'] = witness - return sweep_tx + return True def print_tx_broadcast_result(self, name, res): error = res.get('error') @@ -275,3 +290,69 @@ class LNChanCloseHandler(PrintError): self.print_error('{} broadcast failed: {}'.format(name, error)) else: self.print_error('{} broadcast succeeded'.format(name)) + + +def create_sweeptx_their_ctx_to_remote(network, wallet, ctx, output_idx: int, our_payment_privkey: ecc.ECPrivkey): + our_payment_pubkey = our_payment_privkey.get_public_key_hex(compressed=True) + val = ctx.outputs()[output_idx][2] + sweep_inputs = [{ + 'type': 'p2wpkh', + 'x_pubkeys': [our_payment_pubkey], + 'num_sig': 1, + 'prevout_n': output_idx, + 'prevout_hash': ctx.txid(), + 'value': val, + 'coinbase': False, + 'signatures': [None], + }] + tx_size_bytes = 110 # approx size of p2wpkh->p2wpkh + try: + fee = network.config.estimate_fee(tx_size_bytes) + except NoDynamicFeeEstimates: + fee_per_kb = network.config.fee_per_kb(dyn=False) + fee = network.config.estimate_fee_for_feerate(fee_per_kb, tx_size_bytes) + sweep_outputs = [(TYPE_ADDRESS, wallet.get_receiving_address(), val-fee)] + locktime = network.get_local_height() + sweep_tx = Transaction.from_io(sweep_inputs, sweep_outputs, locktime=locktime) + sweep_tx.set_rbf(True) + sweep_tx.sign({our_payment_pubkey: (our_payment_privkey.get_secret_bytes(), True)}) + if not sweep_tx.is_complete(): + raise Exception('channel close sweep tx is not complete') + return sweep_tx + + +def create_sweeptx_ctx_to_local(network, wallet, ctx, output_idx: int, witness_script: str, + privkey: bytes, is_revocation: bool, to_self_delay: int=None): + """Create a txn that sweeps the 'to_local' output of a commitment + transaction into our wallet. + + privkey: either revocation_privkey or localdelayed_privkey + is_revocation: tells us which ^ + """ + val = ctx.outputs()[output_idx][2] + sweep_inputs = [{ + 'scriptSig': '', + 'type': 'p2wsh', + 'signatures': [], + 'num_sig': 0, + 'prevout_n': output_idx, + 'prevout_hash': ctx.txid(), + 'value': val, + 'coinbase': False, + 'preimage_script': witness_script, + }] + if to_self_delay is not None: + sweep_inputs[0]['sequence'] = to_self_delay + tx_size_bytes = 121 # approx size of to_local -> p2wpkh + try: + fee = network.config.estimate_fee(tx_size_bytes) + except NoDynamicFeeEstimates: + fee_per_kb = network.config.fee_per_kb(dyn=False) + fee = network.config.estimate_fee_for_feerate(fee_per_kb, tx_size_bytes) + sweep_outputs = [(TYPE_ADDRESS, wallet.get_receiving_address(), val - fee)] + locktime = network.get_local_height() + sweep_tx = Transaction.from_io(sweep_inputs, sweep_outputs, locktime=locktime, version=2) + sig = sweep_tx.sign_txin(0, privkey) + witness = transaction.construct_witness([sig, int(is_revocation), witness_script]) + sweep_tx.inputs()[0]['witness'] = witness + return sweep_tx