electrum

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

commit a8ce8109bea4210c821e1757f97d398a8c9103b4
parent 238f3c949ca2d23cbe4cc4e5a1ccd15371050663
Author: ThomasV <thomasv@electrum.org>
Date:   Mon, 24 Jun 2019 11:13:18 +0200

Perform breach remedy without sweepstore:
 - add functions to lnsweep
 - lnworker: analyze candidate ctx and htlc_tx
 - watchtower will be optional
 - add test for breach remedy with spent htlcs
 - save tx name as label

Diffstat:
Melectrum/lnchannel.py | 44++++++++++++++++----------------------------
Melectrum/lnpeer.py | 12+++++++++---
Melectrum/lnsweep.py | 129++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Melectrum/lnutil.py | 2+-
Melectrum/lnwatcher.py | 9++++-----
Melectrum/lnworker.py | 89+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Melectrum/tests/regtest/regtest.sh | 83++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Melectrum/tests/test_regtest.py | 7+++++--
8 files changed, 280 insertions(+), 95 deletions(-)

diff --git a/electrum/lnchannel.py b/electrum/lnchannel.py @@ -46,7 +46,8 @@ from .lnutil import (Outpoint, LocalConfig, RemoteConfig, Keypair, OnlyPubkeyKey HTLC_TIMEOUT_WEIGHT, HTLC_SUCCESS_WEIGHT, extract_ctn_from_tx_and_chan, UpdateAddHtlc, funding_output_script, SENT, RECEIVED, LOCAL, REMOTE, HTLCOwner, make_commitment_outputs, ScriptHtlc, PaymentFailure, calc_onchain_fees, RemoteMisbehaving, make_htlc_output_witness_script) -from .lnsweep import create_sweeptxs_for_their_revoked_ctx, create_sweeptxs_for_our_ctx, create_sweeptxs_for_their_ctx +from .lnsweep import create_sweeptxs_for_our_ctx, create_sweeptxs_for_their_ctx +from .lnsweep import create_sweeptx_for_their_revoked_htlc from .lnhtlc import HTLCManager @@ -165,7 +166,7 @@ class Channel(Logger): self.set_state('DISCONNECTED') self.local_commitment = None self.remote_commitment = None - self.sweep_info = None + self.sweep_info = {} def get_payments(self): out = {} @@ -450,12 +451,6 @@ class Channel(Logger): point = secret_to_pubkey(int.from_bytes(secret, 'big')) return secret, point - def process_new_revocation_secret(self, per_commitment_secret: bytes): - outpoint = self.funding_outpoint.to_str() - ctx = self.remote_commitment_to_be_revoked # FIXME can't we just reconstruct it? - sweeptxs = create_sweeptxs_for_their_revoked_ctx(self, ctx, per_commitment_secret, self.sweep_address) - return sweeptxs - def receive_revocation(self, revocation: RevokeAndAck): self.logger.info("receive_revocation") @@ -469,16 +464,7 @@ class Channel(Logger): # this might break prev_remote_commitment = self.pending_commitment(REMOTE) self.config[REMOTE].revocation_store.add_next_entry(revocation.per_commitment_secret) - - # be robust to exceptions raised in lnwatcher - try: - sweeptxs = self.process_new_revocation_secret(revocation.per_commitment_secret) - except Exception as e: - self.logger.info("Could not process revocation secret: {}".format(repr(e))) - sweeptxs = [] - ##### start applying fee/htlc changes - if self.pending_fee is not None: if not self.constraints.is_initiator: self.pending_fee[FUNDEE_SIGNED] = True @@ -501,8 +487,6 @@ class Channel(Logger): self.set_remote_commitment() self.remote_commitment_to_be_revoked = prev_remote_commitment - # return sweep transactions for watchtower - return sweeptxs def balance(self, whose, *, ctx_owner=HTLCOwner.LOCAL, ctn=None): """ @@ -810,19 +794,23 @@ class Channel(Logger): assert tx.is_complete() return tx - def get_sweep_info(self, ctx: Transaction): - if self.sweep_info is None: + def sweep_ctx(self, ctx: Transaction): + txid = ctx.txid() + if self.sweep_info.get(txid) is None: ctn = extract_ctn_from_tx_and_chan(ctx, self) our_sweep_info = create_sweeptxs_for_our_ctx(self, ctx, ctn, self.sweep_address) their_sweep_info = create_sweeptxs_for_their_ctx(self, ctx, ctn, self.sweep_address) - if our_sweep_info: - self.sweep_info = our_sweep_info + if our_sweep_info is not None: + self.sweep_info[txid] = our_sweep_info self.logger.info(f'we force closed.') - elif their_sweep_info: - self.sweep_info = their_sweep_info + elif their_sweep_info is not None: + self.sweep_info[txid] = their_sweep_info self.logger.info(f'they force closed.') else: - self.sweep_info = {} + self.sweep_info[txid] = {} self.logger.info(f'not sure who closed {ctx}.') - self.logger.info(f'{repr(self.sweep_info)}') - return self.sweep_info + return self.sweep_info[txid] + + def sweep_htlc(self, ctx:Transaction, htlc_tx: Transaction): + # look at the output address, check if it matches + return create_sweeptx_for_their_revoked_htlc(self, ctx, htlc_tx, self.sweep_address) diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py @@ -40,6 +40,7 @@ from .lnutil import (Outpoint, LocalConfig, RECEIVED, UpdateAddHtlc, LightningPeerConnectionClosed, HandshakeFailed, NotFoundChanAnnouncementForUpdate, MINIMUM_MAX_HTLC_VALUE_IN_FLIGHT_ACCEPTED, MAXIMUM_HTLC_MINIMUM_MSAT_ACCEPTED, MAXIMUM_REMOTE_TO_SELF_DELAY_ACCEPTED, RemoteMisbehaving, DEFAULT_TO_SELF_DELAY) +from .lnsweep import create_sweeptxs_for_watchtower from .lntransport import LNTransport, LNTransportBase from .lnmsg import encode_msg, decode_msg from .interface import GracefulDisconnect @@ -1261,15 +1262,20 @@ class Peer(Logger): self.logger.info("on_revoke_and_ack") channel_id = payload["channel_id"] chan = self.channels[channel_id] - sweeptxs = chan.receive_revocation(RevokeAndAck(payload["per_commitment_secret"], payload["next_per_commitment_point"])) + ctx = chan.remote_commitment_to_be_revoked # FIXME can't we just reconstruct it? + rev = RevokeAndAck(payload["per_commitment_secret"], payload["next_per_commitment_point"]) + chan.receive_revocation(rev) self._remote_changed_events[chan.channel_id].set() self._remote_changed_events[chan.channel_id].clear() self.lnworker.save_channel(chan) self.maybe_send_commitment(chan) - asyncio.ensure_future(self._on_revoke_and_ack(chan, sweeptxs)) + asyncio.ensure_future(self._on_revoke_and_ack(chan, ctx, rev.per_commitment_secret)) - async def _on_revoke_and_ack(self, chan, sweeptxs): + @ignore_exceptions + @log_exceptions + async def _on_revoke_and_ack(self, chan, ctx, per_commitment_secret): outpoint = chan.funding_outpoint.to_str() + sweeptxs = create_sweeptxs_for_watchtower(chan, ctx, per_commitment_secret, chan.sweep_address) for tx in sweeptxs: await self.lnwatcher.add_sweep_tx(outpoint, tx.prevout(0), str(tx)) diff --git a/electrum/lnsweep.py b/electrum/lnsweep.py @@ -27,8 +27,8 @@ _logger = get_logger(__name__) -def create_sweeptxs_for_their_revoked_ctx(chan: 'Channel', ctx: Transaction, per_commitment_secret: bytes, - sweep_address: str) -> Dict[str,Transaction]: +def create_sweeptxs_for_watchtower(chan: 'Channel', ctx: Transaction, per_commitment_secret: bytes, + sweep_address: str) -> Dict[str,Transaction]: """Presign sweeping transactions using the just received revoked pcs. These will only be utilised if the remote breaches. Sweep 'to_local', and all the HTLCs (two cases: directly from ctx, or from HTLC tx). @@ -75,8 +75,9 @@ def create_sweeptxs_for_their_revoked_ctx(chan: 'Channel', ctx: Transaction, per sweep_address=sweep_address, privkey=other_revocation_privkey, is_revocation=True) + ctn = extract_ctn_from_tx_and_chan(ctx, chan) - assert ctn == chan.config[REMOTE].ctn + assert ctn == chan.config[REMOTE].ctn - 1 # received HTLCs, in their ctx received_htlcs = chan.included_htlcs(REMOTE, RECEIVED, ctn) for htlc in received_htlcs: @@ -92,6 +93,68 @@ def create_sweeptxs_for_their_revoked_ctx(chan: 'Channel', ctx: Transaction, per return txs +def create_sweeptx_for_their_revoked_ctx(chan: 'Channel', ctx: Transaction, per_commitment_secret: bytes, + sweep_address: str) -> Dict[str,Transaction]: + # prep + pcp = ecc.ECPrivkey(per_commitment_secret).get_public_key_bytes(compressed=True) + this_conf, other_conf = get_ordered_channel_configs(chan=chan, for_us=False) + other_revocation_privkey = derive_blinded_privkey(other_conf.revocation_basepoint.privkey, + per_commitment_secret) + to_self_delay = other_conf.to_self_delay + this_delayed_pubkey = derive_pubkey(this_conf.delayed_basepoint.pubkey, pcp) + txs = [] + # to_local + revocation_pubkey = ecc.ECPrivkey(other_revocation_privkey).get_public_key_bytes(compressed=True) + witness_script = bh2u(make_commitment_output_to_local_witness_script( + revocation_pubkey, to_self_delay, this_delayed_pubkey)) + to_local_address = redeem_script_to_address('p2wsh', witness_script) + output_idx = ctx.get_output_idx_from_address(to_local_address) + if output_idx is not None: + sweep_tx = lambda: create_sweeptx_ctx_to_local( + sweep_address=sweep_address, + ctx=ctx, + output_idx=output_idx, + witness_script=witness_script, + privkey=other_revocation_privkey, + is_revocation=True) + return sweep_tx + +def create_sweeptx_for_their_revoked_htlc(chan: 'Channel', ctx: Transaction, htlc_tx: Transaction, + sweep_address: str) -> Dict[str,Transaction]: + x = analyze_ctx(chan, ctx) + if not x: + return + ctn, their_pcp, is_revocation, per_commitment_secret = x + if not is_revocation: + return + # prep + pcp = ecc.ECPrivkey(per_commitment_secret).get_public_key_bytes(compressed=True) + this_conf, other_conf = get_ordered_channel_configs(chan=chan, for_us=False) + other_revocation_privkey = derive_blinded_privkey(other_conf.revocation_basepoint.privkey, + per_commitment_secret) + to_self_delay = other_conf.to_self_delay + this_delayed_pubkey = derive_pubkey(this_conf.delayed_basepoint.pubkey, pcp) + # same witness script as to_local + revocation_pubkey = ecc.ECPrivkey(other_revocation_privkey).get_public_key_bytes(compressed=True) + witness_script = bh2u(make_commitment_output_to_local_witness_script( + revocation_pubkey, to_self_delay, this_delayed_pubkey)) + htlc_address = redeem_script_to_address('p2wsh', witness_script) + # check that htlc_tx is a htlc + if htlc_tx.outputs()[0].address != htlc_address: + return + + gen_tx = lambda: create_sweeptx_ctx_to_local( + sweep_address=sweep_address, + ctx=htlc_tx, + output_idx=0, + witness_script=witness_script, + privkey=other_revocation_privkey, + is_revocation=True) + + return 'redeem_htlc2', 0, 0, gen_tx + + + def create_sweeptxs_for_our_ctx(chan: 'Channel', ctx: Transaction, ctn: int, sweep_address: str) -> Dict[str,Transaction]: """Handle the case where we force close unilaterally with our latest ctx. @@ -99,6 +162,7 @@ def create_sweeptxs_for_our_ctx(chan: 'Channel', ctx: Transaction, ctn: int, 'to_local' can be swept even if this is a breach (by us), but HTLCs cannot (old HTLCs are no longer stored). """ + ctn = extract_ctn_from_tx_and_chan(ctx, chan) our_conf, their_conf = get_ordered_channel_configs(chan=chan, for_us=True) our_per_commitment_secret = get_per_commitment_secret_from_seed( our_conf.per_commitment_secret_seed, RevocationStore.START_INDEX - ctn) @@ -116,12 +180,18 @@ def create_sweeptxs_for_our_ctx(chan: 'Channel', ctx: Transaction, ctn: int, to_local_address = redeem_script_to_address('p2wsh', to_local_witness_script) their_payment_pubkey = derive_pubkey(their_conf.payment_basepoint.pubkey, our_pcp) to_remote_address = make_commitment_output_to_remote_address(their_payment_pubkey) - # test ctx + _logger.debug(f'testing our ctx: {to_local_address} {to_remote_address}') if ctx.get_output_idx_from_address(to_local_address) is None\ and ctx.get_output_idx_from_address(to_remote_address) is None: return - + # we have to_local, to_remote. + # other outputs are htlcs + # if they are spent, we need to generate the script + # so, second-stage htlc sweep should not be returned here + if ctn != our_conf.ctn: + _logger.info("we breached.") + return {} txs = {} # to_local output_idx = ctx.get_output_idx_from_address(to_local_address) @@ -155,7 +225,7 @@ def create_sweeptxs_for_our_ctx(chan: 'Channel', ctx: Transaction, ctn: int, preimage=preimage, is_received_htlc=is_received_htlc) sweep_tx = lambda: create_sweeptx_that_spends_htlctx_that_spends_htlc_in_ctx( - 'sweep_from_our_ctx_htlc_', + 'our_ctx_htlc_', to_self_delay=to_self_delay, htlc_tx=htlc_tx, htlctx_witness_script=htlctx_witness_script, @@ -165,6 +235,7 @@ def create_sweeptxs_for_our_ctx(chan: 'Channel', ctx: Transaction, ctn: int, # side effect txs[htlc_tx.prevout(0)] = ('first-stage-htlc', 0, htlc_tx.cltv_expiry, lambda: htlc_tx) txs[htlc_tx.txid() + ':0'] = ('second-stage-htlc', to_self_delay, 0, sweep_tx) + # offered HTLCs, in our ctx --> "timeout" # received HTLCs, in our ctx --> "success" offered_htlcs = chan.included_htlcs(LOCAL, SENT, ctn) # type: List[UpdateAddHtlc] @@ -175,17 +246,11 @@ def create_sweeptxs_for_our_ctx(chan: 'Channel', ctx: Transaction, ctn: int, create_txns_for_htlc(htlc, is_received_htlc=True) return txs - -def create_sweeptxs_for_their_ctx(chan: 'Channel', ctx: Transaction, ctn: int, - sweep_address: str) -> Dict[str,Transaction]: - """Handle the case when the remote force-closes with their ctx. - Sweep outputs that do not have a CSV delay ('to_remote' and first-stage HTLCs). - Outputs with CSV delay ('to_local' and second-stage HTLCs) are redeemed by LNWatcher. - """ - our_conf, their_conf = get_ordered_channel_configs(chan=chan, for_us=True) - ctn = extract_ctn_from_tx_and_chan(ctx, chan) +def analyze_ctx(chan, ctx): # note: the remote sometimes has two valid non-revoked commitment transactions, # either of which could be broadcast (their_conf.ctn, their_conf.ctn+1) + our_conf, their_conf = get_ordered_channel_configs(chan=chan, for_us=True) + ctn = extract_ctn_from_tx_and_chan(ctx, chan) per_commitment_secret = None if ctn == their_conf.ctn: their_pcp = their_conf.current_per_commitment_point @@ -200,9 +265,23 @@ def create_sweeptxs_for_their_ctx(chan: 'Channel', ctx: Transaction, ctn: int, return their_pcp = ecc.ECPrivkey(per_commitment_secret).get_public_key_bytes(compressed=True) is_revocation = True - our_revocation_privkey = derive_blinded_privkey(our_conf.revocation_basepoint.privkey, per_commitment_secret) + #_logger.info(f'tx for revoked: {list(txs.keys())}') else: return + return ctn, their_pcp, is_revocation, per_commitment_secret + +def create_sweeptxs_for_their_ctx(chan: 'Channel', ctx: Transaction, ctn: int, + sweep_address: str) -> Dict[str,Transaction]: + """Handle the case when the remote force-closes with their ctx. + Sweep outputs that do not have a CSV delay ('to_remote' and first-stage HTLCs). + Outputs with CSV delay ('to_local' and second-stage HTLCs) are redeemed by LNWatcher. + """ + txs = {} + our_conf, their_conf = get_ordered_channel_configs(chan=chan, for_us=True) + x = analyze_ctx(chan, ctx) + if not x: + return + ctn, their_pcp, is_revocation, per_commitment_secret = x # to_local and to_remote addresses our_revocation_pubkey = derive_blinded_pubkey(our_conf.revocation_basepoint.pubkey, their_pcp) their_delayed_pubkey = derive_pubkey(their_conf.delayed_basepoint.pubkey, their_pcp) @@ -211,10 +290,18 @@ def create_sweeptxs_for_their_ctx(chan: 'Channel', ctx: Transaction, ctn: int, to_local_address = redeem_script_to_address('p2wsh', witness_script) our_payment_pubkey = derive_pubkey(our_conf.payment_basepoint.pubkey, their_pcp) to_remote_address = make_commitment_output_to_remote_address(our_payment_pubkey) - # test ctx + # test if this is their ctx + _logger.debug(f'testing their ctx: {to_local_address} {to_remote_address}') if ctx.get_output_idx_from_address(to_local_address) is None \ and ctx.get_output_idx_from_address(to_remote_address) is None: return + + if is_revocation: + our_revocation_privkey = derive_blinded_privkey(our_conf.revocation_basepoint.privkey, per_commitment_secret) + gen_tx = create_sweeptx_for_their_revoked_ctx(chan, ctx, per_commitment_secret, chan.sweep_address) + if gen_tx: + tx = gen_tx() + txs[tx.prevout(0)] = ('to_local_for_revoked_ctx', 0, 0, gen_tx) # prep our_htlc_privkey = derive_privkey(secret=int.from_bytes(our_conf.htlc_basepoint.privkey, 'big'), per_commitment_point=their_pcp) our_htlc_privkey = ecc.ECPrivkey.from_secret_scalar(our_htlc_privkey) @@ -223,7 +310,6 @@ def create_sweeptxs_for_their_ctx(chan: 'Channel', ctx: Transaction, ctn: int, our_payment_privkey = derive_privkey(our_payment_bp_privkey.secret_scalar, their_pcp) our_payment_privkey = ecc.ECPrivkey.from_secret_scalar(our_payment_privkey) assert our_payment_pubkey == our_payment_privkey.get_public_key_bytes(compressed=True) - txs = {} # to_local is handled by lnwatcher # to_remote output_idx = ctx.get_output_idx_from_address(to_remote_address) @@ -268,7 +354,7 @@ def create_sweeptxs_for_their_ctx(chan: 'Channel', ctx: Transaction, ctn: int, privkey=our_revocation_privkey if is_revocation else our_htlc_privkey.get_secret_bytes(), is_revocation=is_revocation, cltv_expiry=cltv_expiry) - name = f'their_ctx_sweep_htlc_{ctx.txid()[:8]}_{output_idx}' + name = f'their_ctx_htlc_{output_idx}' txs[prevout] = (name, 0, cltv_expiry, sweep_tx) # received HTLCs, in their ctx --> "timeout" received_htlcs = chan.included_htlcs(REMOTE, RECEIVED, ctn=ctn) # type: List[UpdateAddHtlc] @@ -327,7 +413,7 @@ def create_sweeptx_their_ctx_htlc(ctx: Transaction, witness_script: bytes, sweep if outvalue <= dust_threshold(): return None sweep_outputs = [TxOutput(TYPE_ADDRESS, sweep_address, outvalue)] tx = Transaction.from_io(sweep_inputs, sweep_outputs, version=2 - , name=f'their_ctx_sweep_htlc_{ctx.txid()[:8]}_{output_idx}' + , name=f'their_ctx_htlc_{output_idx}' # note that cltv_expiry, and therefore also locktime will be zero when breach! , cltv_expiry=cltv_expiry, locktime=cltv_expiry) sig = bfh(tx.sign_txin(0, privkey)) @@ -431,7 +517,8 @@ def create_sweeptx_that_spends_htlctx_that_spends_htlc_in_ctx( outvalue = val - fee if outvalue <= dust_threshold(): return None sweep_outputs = [TxOutput(TYPE_ADDRESS, sweep_address, outvalue)] - tx = Transaction.from_io(sweep_inputs, sweep_outputs, version=2, name=name_prefix + htlc_tx.txid(), csv_delay=to_self_delay) + name = name_prefix + htlc_tx.txid()[0:4] + tx = Transaction.from_io(sweep_inputs, sweep_outputs, version=2, name=name, csv_delay=to_self_delay) sig = bfh(tx.sign_txin(0, privkey)) witness = construct_witness([sig, int(is_revocation), htlctx_witness_script]) diff --git a/electrum/lnutil.py b/electrum/lnutil.py @@ -359,7 +359,7 @@ def make_htlc_tx_with_open_channel(chan: 'Channel', pcp: bytes, for_us: bool, is_htlc_success = for_us == we_receive script, htlc_tx_output = make_htlc_tx_output( amount_msat = amount_msat, - local_feerate = chan.pending_feerate(LOCAL if for_us else REMOTE), + local_feerate = chan.pending_feerate(LOCAL if for_us else REMOTE), # uses pending feerate.. revocationpubkey=other_revocation_pubkey, local_delayedpubkey=delayedpubkey, success = is_htlc_success, diff --git a/electrum/lnwatcher.py b/electrum/lnwatcher.py @@ -248,7 +248,7 @@ class LNWatcher(AddressSynchronizer): self.network.trigger_callback('channel_closed', funding_outpoint, spenders, funding_txid, funding_height, closing_txid, closing_height, closing_tx) # FIXME sooo many args.. - await self.do_breach_remedy(funding_outpoint, spenders) + #await self.do_breach_remedy(funding_outpoint, spenders) if not keep_watching: await self.unwatch_channel(address, funding_outpoint) else: @@ -289,8 +289,7 @@ class LNWatcher(AddressSynchronizer): continue sweep_txns = await self.sweepstore.get_sweep_tx(funding_outpoint, prevout) for tx in sweep_txns: - if not await self.broadcast_or_log(funding_outpoint, tx): - self.logger.info(f'{tx.name} could not publish tx: {str(tx)}, prevout: {prevout}') + await self.broadcast_or_log(funding_outpoint, tx) async def broadcast_or_log(self, funding_outpoint, tx): height = self.get_tx_height(tx.txid()).height @@ -299,9 +298,9 @@ class LNWatcher(AddressSynchronizer): try: txid = await self.network.broadcast_transaction(tx) except Exception as e: - self.logger.info(f'broadcast: {tx.name}: failure: {repr(e)}') + self.logger.info(f'broadcast failure: {tx.name}: {repr(e)}') else: - self.logger.info(f'broadcast: {tx.name}: success. txid: {txid}') + self.logger.info(f'broadcast success: {tx.name}') if funding_outpoint in self.tx_progress: await self.tx_progress[funding_outpoint].tx_queue.put(tx) return txid diff --git a/electrum/lnworker.py b/electrum/lnworker.py @@ -514,45 +514,66 @@ class LNWallet(LNWorker): # remove from channel_db if chan.short_channel_id is not None: self.channel_db.remove_channel(chan.short_channel_id) - # detect who closed and set sweep_info - sweep_info = chan.get_sweep_info(closing_tx) - + sweep_info = chan.sweep_ctx(closing_tx) # create and broadcast transaction for prevout, e_tx in sweep_info.items(): name, csv_delay, cltv_expiry, gen_tx = e_tx - if spenders.get(prevout) is not None: - self.logger.info(f'outpoint already spent {prevout}') - continue - prev_txid, prev_index = prevout.split(':') - broadcast = True - if cltv_expiry: - local_height = self.network.get_local_height() - remaining = cltv_expiry - local_height - if remaining > 0: - self.logger.info('waiting for {}: CLTV ({} > {}), funding outpoint {} and tx {}' - .format(name, local_height, cltv_expiry, funding_outpoint[:8], prev_txid[:8])) - broadcast = False - if csv_delay: - prev_height = self.network.lnwatcher.get_tx_height(prev_txid) - remaining = csv_delay - prev_height.conf - if remaining > 0: - self.logger.info('waiting for {}: CSV ({} >= {}), funding outpoint {} and tx {}' - .format(name, prev_height.conf, csv_delay, funding_outpoint[:8], prev_txid[:8])) - broadcast = False - tx = gen_tx() - if tx is None: - self.logger.info(f'{name} could not claim output: {prevout}, dust') - if broadcast: - if not await self.network.lnwatcher.broadcast_or_log(funding_outpoint, tx): - self.logger.info(f'{name} could not publish encumbered tx: {str(tx)}, prevout: {prevout}') + spender = spenders.get(prevout) + if spender is not None: + spender_tx = await self.network.get_transaction(spender) + spender_tx = Transaction(spender_tx) + e_htlc_tx = chan.sweep_htlc(closing_tx, spender_tx) + if e_htlc_tx: + spender2 = spenders.get(spender_tx.outputs()[0]) + if spender2: + self.logger.info(f'htlc is already spent {name}: {prevout}') + else: + self.logger.info(f'trying to redeem htlc {name}: {prevout}') + await self.try_redeem(spender+':0', e_htlc_tx) + else: + self.logger.info(f'outpoint already spent {name}: {prevout}') else: - # it's OK to add local transaction, the fee will be recomputed - try: - self.wallet.add_future_tx(tx, remaining) - self.logger.info(f'adding future tx: {name}. prevout: {prevout}') - except Exception as e: - self.logger.info(f'could not add future tx: {name}. prevout: {prevout} {str(e)}') + self.logger.info(f'trying to redeem {name}: {prevout}') + await self.try_redeem(prevout, e_tx) + + @log_exceptions + async def try_redeem(self, prevout, e_tx): + name, csv_delay, cltv_expiry, gen_tx = e_tx + prev_txid, prev_index = prevout.split(':') + broadcast = True + if cltv_expiry: + local_height = self.network.get_local_height() + remaining = cltv_expiry - local_height + if remaining > 0: + self.logger.info('waiting for {}: CLTV ({} > {}), prevout {}' + .format(name, local_height, cltv_expiry, prevout)) + broadcast = False + if csv_delay: + prev_height = self.network.lnwatcher.get_tx_height(prev_txid) + remaining = csv_delay - prev_height.conf + if remaining > 0: + self.logger.info('waiting for {}: CSV ({} >= {}), prevout: {}' + .format(name, prev_height.conf, csv_delay, prevout)) + broadcast = False + tx = gen_tx() + self.wallet.set_label(tx.txid(), name) + if tx is None: + self.logger.info(f'{name} could not claim output: {prevout}, dust') + if broadcast: + try: + await self.network.broadcast_transaction(tx) + except Exception as e: + self.logger.info(f'could NOT publish {name} for prevout: {prevout}, {str(e)}') + else: + self.logger.info(f'success: broadcasting {name} for prevout: {prevout}') + else: + # it's OK to add local transaction, the fee will be recomputed + try: + self.wallet.add_future_tx(tx, remaining) + self.logger.info(f'adding future tx: {name}. prevout: {prevout}') + except Exception as e: + self.logger.info(f'could not add future tx: {name}. prevout: {prevout} {str(e)}') def is_dangerous(self, chan): for x in chan.get_unfulfilled_htlcs(): diff --git a/electrum/tests/regtest/regtest.sh b/electrum/tests/regtest/regtest.sh @@ -157,7 +157,7 @@ if [[ $1 == "redeem_htlcs" ]]; then fi -if [[ $1 == "breach_with_htlc" ]]; then +if [[ $1 == "breach_with_unspent_htlc" ]]; then $bob daemon stop ELECTRUM_DEBUG_LIGHTNING_SETTLE_DELAY=3 $bob daemon -s 127.0.0.1:51001:t start $bob daemon load_wallet @@ -218,3 +218,84 @@ if [[ $1 == "breach_with_htlc" ]]; then exit 1 fi fi + + +if [[ $1 == "breach_with_spent_htlc" ]]; then + $bob daemon stop + ELECTRUM_DEBUG_LIGHTNING_SETTLE_DELAY=3 $bob daemon -s 127.0.0.1:51001:t start + $bob daemon load_wallet + while alice_balance=$($alice getbalance | jq '.confirmed' | tr -d '"') && [ $alice_balance != "1" ]; do + echo "waiting for alice balance" + sleep 1 + done + echo "alice opens channel" + bob_node=$($bob nodeid) + channel=$($alice open_channel $bob_node 0.15) + new_blocks 3 + channel_state="" + while channel_state=$($alice list_channels | jq '.[] | .state' | tr -d '"') && [ $channel_state != "OPEN" ]; do + echo "waiting for channel open" + sleep 1 + done + echo "alice pays bob" + invoice=$($bob addinvoice 0.05 "test") + $alice lnpay $invoice --timeout=1 || true + settled=$($alice list_channels | jq '.[] | .local_htlcs | .settles | length') + if [[ "$settled" != "0" ]]; then + echo "SETTLE_DELAY did not work, $settled != 0" + exit 1 + fi + ctx=$($alice get_channel_ctx $channel | jq '.hex' | tr -d '"') + cp /tmp/alice/regtest/wallets/default_wallet /tmp/alice/regtest/wallets/toxic_wallet + sleep 5 + settled=$($alice list_channels | jq '.[] | .local_htlcs | .settles | length') + if [[ "$settled" != "1" ]]; then + echo "SETTLE_DELAY did not work, $settled != 1" + exit 1 + fi + echo $($bob getbalance) + echo "bob goes offline" + $bob daemon stop + ctx_id=$($bitcoin_cli sendrawtransaction $ctx) + echo "alice breaches with old ctx:" $ctx_id + new_blocks 1 + if [[ $($bitcoin_cli gettxout $ctx_id 0 | jq '.confirmations') != "1" ]]; then + echo "breach tx not confirmed" + exit 1 + fi + echo "wait for cltv_expiry blocks" + # note: this will let alice redeem to_local + # because cltv_delay is the same as csv_delay + new_blocks 144 + echo "alice spends to_local and htlc outputs" + $alice daemon stop + cp /tmp/alice/regtest/wallets/toxic_wallet /tmp/alice/regtest/wallets/default_wallet + $alice daemon -s 127.0.0.1:51001:t start + $alice daemon load_wallet + # wait until alice has spent both ctx outputs + while [[ $($bitcoin_cli gettxout $ctx_id 0) ]]; do + echo "waiting until alice spends ctx outputs" + sleep 1 + done + while [[ $($bitcoin_cli gettxout $ctx_id 1) ]]; do + echo "waiting until alice spends ctx outputs" + sleep 1 + done + new_blocks 1 + echo "bob comes back" + $bob daemon -s 127.0.0.1:51001:t start + $bob daemon load_wallet + while [[ $($bitcoin_cli getmempoolinfo | jq '.size') != "1" ]]; do + echo "waiting for bob's transaction" + sleep 1 + done + echo "mempool has 1 tx" + new_blocks 1 + sleep 5 + balance=$($bob getbalance | jq '.confirmed') + if (( $(echo "$balance < 0.049" | bc -l) )); then + echo "htlc not redeemed." + exit 1 + fi + echo "bob balance $balance" +fi diff --git a/electrum/tests/test_regtest.py b/electrum/tests/test_regtest.py @@ -33,5 +33,8 @@ class TestLightning(unittest.TestCase): def test_redeem_htlcs(self): self.run_shell(['redeem_htlcs']) - def test_breach_with_htlc(self): - self.run_shell(['breach_with_htlc']) + def test_breach_with_unspent_htlc(self): + self.run_shell(['breach_with_unspent_htlc']) + + def test_breach_with_spent_htlc(self): + self.run_shell(['breach_with_spent_htlc'])