diff --git a/electrum/lnchannel.py b/electrum/lnchannel.py index 9801decd0..2f1dcb768 100644 --- a/electrum/lnchannel.py +++ b/electrum/lnchannel.py @@ -903,7 +903,7 @@ class Channel(AbstractChannel): def has_anchors(self) -> bool: channel_type = ChannelType(self.storage.get('channel_type')) - return bool(channel_type & ChannelType.OPTION_ANCHOR_OUTPUTS) + return bool(channel_type & ChannelType.OPTION_ANCHORS_ZERO_FEE_HTLC_TX) def get_wallet_addresses_channel_might_want_reserved(self) -> Sequence[str]: assert self.is_static_remotekey_enabled() diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py index 73a1b9305..a8a60382b 100644 --- a/electrum/lnpeer.py +++ b/electrum/lnpeer.py @@ -657,7 +657,7 @@ class Peer(Logger, EventListener): return self.features.supports(LnFeatures.OPTION_UPFRONT_SHUTDOWN_SCRIPT_OPT) def use_anchors(self) -> bool: - return self.features.supports(LnFeatures.OPTION_ANCHOR_OUTPUTS_OPT) + return self.features.supports(LnFeatures.OPTION_ANCHORS_ZERO_FEE_HTLC_OPT) def upfront_shutdown_script_from_payload(self, payload, msg_identifier: str) -> Optional[bytes]: if msg_identifier not in ['accept', 'open']: @@ -774,7 +774,7 @@ class Peer(Logger, EventListener): assert self.their_features.supports(LnFeatures.OPTION_STATIC_REMOTEKEY_OPT) our_channel_type = ChannelType(ChannelType.OPTION_STATIC_REMOTEKEY) if self.use_anchors(): - our_channel_type |= ChannelType(ChannelType.OPTION_ANCHOR_OUTPUTS) + our_channel_type |= ChannelType(ChannelType.OPTION_ANCHORS_ZERO_FEE_HTLC_TX) if zeroconf: our_channel_type |= ChannelType(ChannelType.OPTION_ZEROCONF) # We do not set the option_scid_alias bit in channel_type because LND rejects it. diff --git a/electrum/lnsweep.py b/electrum/lnsweep.py index 148182580..39f8d5d14 100644 --- a/electrum/lnsweep.py +++ b/electrum/lnsweep.py @@ -7,11 +7,13 @@ from enum import Enum, auto import electrum_ecc as ecc -from .util import bfh +from .util import bfh, UneconomicFee from .crypto import privkey_to_pubkey from .bitcoin import redeem_script_to_address, dust_threshold, construct_witness from .invoices import PR_PAID from . import descriptor +from . import coinchooser + from .lnutil import (make_commitment_output_to_remote_address, make_commitment_output_to_local_witness_script, derive_privkey, derive_pubkey, derive_blinded_pubkey, derive_blinded_privkey, make_htlc_tx_witness, make_htlc_tx_with_open_channel, UpdateAddHtlc, @@ -32,6 +34,9 @@ if TYPE_CHECKING: _logger = get_logger(__name__) # note: better to use chan.logger instead, when applicable +HTLC_TRANSACTION_DEADLINE_FRACTION = 4 +HTLC_TRANSACTION_SWEEP_TARGET = 10 + class SweepInfo(NamedTuple): name: str @@ -315,12 +320,15 @@ def create_sweeptxs_for_our_ctx( continue else: preimage = None - create_txns_for_htlc( - htlc=htlc, - htlc_direction=direction, - ctx_output_idx=ctx_output_idx, - htlc_relative_idx=htlc_relative_idx, - preimage=preimage) + try: + create_txns_for_htlc( + htlc=htlc, + htlc_direction=direction, + ctx_output_idx=ctx_output_idx, + htlc_relative_idx=htlc_relative_idx, + preimage=preimage) + except UneconomicFee: + continue return txs @@ -580,7 +588,7 @@ def create_htlctx_that_spends_from_our_ctx( assert (htlc_direction == RECEIVED) == bool(preimage), 'preimage is required iff htlc is received' preimage = preimage or b'' ctn = extract_ctn_from_tx_and_chan(ctx, chan) - witness_script_out, htlc_tx = make_htlc_tx_with_open_channel( + witness_script_out, maybe_zero_fee_htlc_tx = make_htlc_tx_with_open_channel( chan=chan, pcp=our_pcp, subject=LOCAL, @@ -590,13 +598,87 @@ def create_htlctx_that_spends_from_our_ctx( htlc=htlc, ctx_output_idx=ctx_output_idx, name=f'our_ctx_{ctx_output_idx}_htlc_tx_{htlc.payment_hash.hex()}') + + # we need to attach inputs that pay for the transaction fee + if chan.has_anchors(): + wallet = chan.lnworker.wallet + coins = wallet.get_spendable_coins(None) + + def fee_estimator(size): + if htlc_direction == SENT: + # we deal with an offered HTLC and therefore with a timeout transaction + # in this case it is not time critical for us to sweep unless we + # become a forwarding node + fee_per_kb = wallet.config.eta_target_to_fee(HTLC_TRANSACTION_SWEEP_TARGET) + else: + # in the case of a received HTLC, if we have the hash preimage, + # we should sweep before the timelock expires + expiry_height = htlc.cltv_abs + current_height = wallet.network.blockchain().height() + deadline_blocks = expiry_height - current_height + # target block inclusion with a safety buffer + target = int(deadline_blocks / HTLC_TRANSACTION_DEADLINE_FRACTION) + fee_per_kb = wallet.config.eta_target_to_fee(target) + if not fee_per_kb: # testnet and other cases + fee_per_kb = wallet.config.fee_per_kb() + fee = wallet.config.estimate_fee_for_feerate(fee_per_kb=fee_per_kb, size=size) + # we only sweep if it is makes sense economically + if fee > htlc.amount_msat // 1000: + raise UneconomicFee + return fee + + coin_chooser = coinchooser.get_coin_chooser(wallet.config) + change_address = wallet.get_single_change_address_for_new_transaction() + funded_htlc_tx = coin_chooser.make_tx( + coins=coins, + inputs=maybe_zero_fee_htlc_tx.inputs(), + outputs=maybe_zero_fee_htlc_tx.outputs(), + change_addrs=[change_address], + fee_estimator_vb=fee_estimator, + dust_threshold=wallet.dust_threshold()) + + # place htlc input/output at corresponding indices (due to sighash single) + htlc_outpoint = TxOutpoint(txid=bfh(ctx.txid()), out_idx=ctx_output_idx) + htlc_input_idx = funded_htlc_tx.get_input_idx_that_spent_prevout(htlc_outpoint) + + htlc_out_address = maybe_zero_fee_htlc_tx.outputs()[0].address + htlc_output_idx = funded_htlc_tx.get_output_idxs_from_address(htlc_out_address).pop() + inputs = funded_htlc_tx.inputs() + outputs = funded_htlc_tx.outputs() + if htlc_input_idx != 0: + htlc_txin = inputs.pop(htlc_input_idx) + inputs.insert(0, htlc_txin) + if htlc_output_idx != 0: + htlc_txout = outputs.pop(htlc_output_idx) + outputs.insert(0, htlc_txout) + final_htlc_tx = PartialTransaction.from_io( + inputs, + outputs, + locktime=maybe_zero_fee_htlc_tx.locktime, + version=maybe_zero_fee_htlc_tx.version, + BIP69_sort=False + ) + + for fee_input_idx in range(1, len(funded_htlc_tx.inputs())): + txin = final_htlc_tx.inputs()[fee_input_idx] + pubkey = wallet.get_public_key(txin.address) + index = wallet.get_address_index(txin.address) + privkey, _ = wallet.keystore.get_private_key(index, wallet.get_unlocked_password()) + desc = descriptor.get_singlesig_descriptor_from_legacy_leaf(pubkey=pubkey, script_type='p2wpkh') + txin.script_descriptor = desc + fee_input_sig = final_htlc_tx.sign_txin(fee_input_idx, privkey) + final_htlc_tx.add_signature_to_txin(txin_idx=fee_input_idx, signing_pubkey=bfh(pubkey), sig=fee_input_sig) + else: + final_htlc_tx = maybe_zero_fee_htlc_tx + + # sign HTLC output remote_htlc_sig = chan.get_remote_htlc_sig_for_htlc(htlc_relative_idx=htlc_relative_idx) - local_htlc_sig = htlc_tx.sign_txin(0, local_htlc_privkey) - txin = htlc_tx.inputs()[0] + local_htlc_sig = final_htlc_tx.sign_txin(0, local_htlc_privkey) + txin = final_htlc_tx.inputs()[0] witness_script_in = txin.witness_script assert witness_script_in txin.witness = make_htlc_tx_witness(remote_htlc_sig, local_htlc_sig, preimage, witness_script_in) - return witness_script_out, htlc_tx + return witness_script_out, final_htlc_tx def create_sweeptx_their_ctx_htlc( diff --git a/electrum/lnutil.py b/electrum/lnutil.py index 826e661b5..61c7e3789 100644 --- a/electrum/lnutil.py +++ b/electrum/lnutil.py @@ -1061,7 +1061,7 @@ def effective_htlc_tx_weight(success: bool, has_anchors: bool): # the fees for the hltc transaction don't need to be subtracted from # the htlc output, but fees are taken from extra attached inputs if has_anchors: - return HTLC_SUCCESS_WEIGHT_ANCHORS if success else HTLC_TIMEOUT_WEIGHT_ANCHORS + return 0 * HTLC_SUCCESS_WEIGHT_ANCHORS if success else 0 * HTLC_TIMEOUT_WEIGHT_ANCHORS else: return HTLC_SUCCESS_WEIGHT if success else HTLC_TIMEOUT_WEIGHT @@ -1552,7 +1552,7 @@ LN_FEATURES_IMPLEMENTED = ( | LnFeatures.OPTION_SHUTDOWN_ANYSEGWIT_OPT | LnFeatures.OPTION_SHUTDOWN_ANYSEGWIT_REQ | LnFeatures.OPTION_CHANNEL_TYPE_OPT | LnFeatures.OPTION_CHANNEL_TYPE_REQ | LnFeatures.OPTION_SCID_ALIAS_OPT | LnFeatures.OPTION_SCID_ALIAS_REQ - | LnFeatures.OPTION_ANCHOR_OUTPUTS_OPT | LnFeatures.OPTION_ANCHOR_OUTPUTS_REQ + | LnFeatures.OPTION_ANCHORS_ZERO_FEE_HTLC_OPT | LnFeatures.OPTION_ANCHORS_ZERO_FEE_HTLC_REQ ) diff --git a/electrum/lnworker.py b/electrum/lnworker.py index 71461c719..4a7f43e50 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -834,7 +834,7 @@ class LNWallet(LNWorker): Logger.__init__(self) features = LNWALLET_FEATURES if self.config.ENABLE_ANCHOR_CHANNELS: - features |= LnFeatures.OPTION_ANCHOR_OUTPUTS_OPT + features |= LnFeatures.OPTION_ANCHORS_ZERO_FEE_HTLC_OPT if self.config.ACCEPT_ZEROCONF_CHANNELS: features |= LnFeatures.OPTION_ZEROCONF_OPT LNWorker.__init__(self, self.node_keypair, features, config=self.config) diff --git a/electrum/util.py b/electrum/util.py index 464af66ba..4571a6e0e 100644 --- a/electrum/util.py +++ b/electrum/util.py @@ -145,6 +145,11 @@ class NotEnoughFunds(Exception): return _("Insufficient funds") +class UneconomicFee(Exception): + def __str__(self): + return _("The fee for the transaction is higher than the funds gained from it.") + + class NoDynamicFeeEstimates(Exception): def __str__(self): return _('Dynamic fee estimates not available') diff --git a/tests/test_lnutil.py b/tests/test_lnutil.py index 68e04718d..719892f2a 100644 --- a/tests/test_lnutil.py +++ b/tests/test_lnutil.py @@ -797,6 +797,9 @@ class TestLNUtil(ElectrumTestCase): ref_commit_tx_str = '02000000000101bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a488489000000000038b02b8002c0c62d0000000000160014ccf1af2f2aabee14bb40fa3851ab2301de84311054a56a00000000002200204adb4e2f00643db396dd120d4e7dc17625f5f2c11a40d857accc862d6b7dd80e0400473044022051b75c73198c6deee1a875871c3961832909acd297c6b908d59e3319e5185a46022055c419379c5051a78d00dbbce11b5b664a0c22815fbcc6fcef6b1937c383693901483045022100f51d2e566a70ba740fc5d8c0f07b9b93d2ed741c3c0860c613173de7d39e7968022041376d520e9c0e1ad52248ddf4b22e12be8763007df977253ef45a4ca3bdb7c001475221023da092f6980e58d2c037173180e9a465476026ee50f96695963e8efe436f54eb21030e9f7b623d2ccc7c9bd44d66d5ce21ce504c0acf6385a132cec6d3c39fa711c152ae3e195220' self.assertEqual(str(our_commit_tx), ref_commit_tx_str) + @unittest.skip("only valid for original anchor ouputs, " + "but invalid due to different fee estimation " + "with anchors-zero-fee-htlcs") @disable_ecdsa_r_value_grinding def test_commitment_tx_anchors_test_vectors(self): for test_vector in ANCHOR_TEST_VECTORS: