From e6ea6dbf0a631bb7f8421c045a74196da5d272a3 Mon Sep 17 00:00:00 2001 From: f321x Date: Thu, 11 Sep 2025 15:18:13 +0200 Subject: [PATCH] lnutil: make UpdateAddHtlc dataclass it is straightforward to move UpdateAddHtlc away from attr to a dataclass without requiring any db update. --- electrum/lnchannel.py | 9 +-- electrum/lnutil.py | 35 +++++++---- tests/test_lnchannel.py | 135 ++++++++++++++++++++++------------------ 3 files changed, 102 insertions(+), 77 deletions(-) diff --git a/electrum/lnchannel.py b/electrum/lnchannel.py index b6e19647d..2d57a4a8b 100644 --- a/electrum/lnchannel.py +++ b/electrum/lnchannel.py @@ -17,6 +17,7 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. +import dataclasses import enum from collections import defaultdict from enum import IntEnum, Enum @@ -1202,12 +1203,10 @@ class Channel(AbstractChannel): """Adds a new LOCAL HTLC to the channel. Action must be initiated by LOCAL. """ - if isinstance(htlc, dict): # legacy conversion # FIXME remove - htlc = UpdateAddHtlc(**htlc) assert isinstance(htlc, UpdateAddHtlc) self._assert_can_add_htlc(htlc_proposer=LOCAL, amount_msat=htlc.amount_msat) if htlc.htlc_id is None: - htlc = attr.evolve(htlc, htlc_id=self.hm.get_next_htlc_id(LOCAL)) + htlc = dataclasses.replace(htlc, htlc_id=self.hm.get_next_htlc_id(LOCAL)) with self.db_lock: self.hm.send_htlc(htlc) self.logger.info("add_htlc") @@ -1217,15 +1216,13 @@ class Channel(AbstractChannel): """Adds a new REMOTE HTLC to the channel. Action must be initiated by REMOTE. """ - if isinstance(htlc, dict): # legacy conversion # FIXME remove - htlc = UpdateAddHtlc(**htlc) assert isinstance(htlc, UpdateAddHtlc) try: self._assert_can_add_htlc(htlc_proposer=REMOTE, amount_msat=htlc.amount_msat) except PaymentFailure as e: raise RemoteMisbehaving(e) from e if htlc.htlc_id is None: # used in unit tests - htlc = attr.evolve(htlc, htlc_id=self.hm.get_next_htlc_id(REMOTE)) + htlc = dataclasses.replace(htlc, htlc_id=self.hm.get_next_htlc_id(REMOTE)) with self.db_lock: self.hm.recv_htlc(htlc) if onion_packet: diff --git a/electrum/lnutil.py b/electrum/lnutil.py index 3903f6979..11cee641c 100644 --- a/electrum/lnutil.py +++ b/electrum/lnutil.py @@ -12,9 +12,10 @@ from functools import lru_cache import electrum_ecc as ecc from electrum_ecc import CURVE_ORDER, ecdsa_sig64_from_der_sig from electrum_ecc.util import bip340_tagged_hash +import dataclasses import attr -from .util import bfh, UserFacingException, list_enabled_bits +from .util import bfh, UserFacingException, list_enabled_bits, is_hex_str from .util import ShortID as ShortChannelID, format_short_id as format_short_channel_id from .crypto import sha256, pw_decode_with_version_and_mac @@ -22,7 +23,8 @@ from .transaction import ( Transaction, PartialTransaction, PartialTxInput, TxOutpoint, PartialTxOutput, opcodes, OPPushDataPubkey ) from . import bitcoin, crypto, transaction, descriptor, segwit_addr -from .bitcoin import redeem_script_to_address, address_to_script, construct_witness, construct_script +from .bitcoin import redeem_script_to_address, address_to_script, construct_witness, \ + construct_script, NLOCKTIME_BLOCKHEIGHT_MAX from .i18n import _ from .bip32 import BIP32Node, BIP32_PRIME from .transaction import BCDataStream, OPPushDataGeneric @@ -1908,26 +1910,37 @@ NUM_MAX_HOPS_IN_PAYMENT_PATH = 20 NUM_MAX_EDGES_IN_PAYMENT_PATH = NUM_MAX_HOPS_IN_PAYMENT_PATH -@attr.s(frozen=True) +@dataclasses.dataclass(frozen=True, kw_only=True) class UpdateAddHtlc: - amount_msat = attr.ib(type=int, kw_only=True) - payment_hash = attr.ib(type=bytes, kw_only=True, converter=hex_to_bytes, repr=lambda val: val.hex()) - cltv_abs = attr.ib(type=int, kw_only=True) - timestamp = attr.ib(type=int, kw_only=True) - htlc_id = attr.ib(type=int, kw_only=True, default=None) + amount_msat: int + payment_hash: bytes + cltv_abs: int + htlc_id: Optional[int] = dataclasses.field(default=None) + timestamp: int = dataclasses.field(default_factory=lambda: int(time.time())) @staticmethod @stored_in('adds', tuple) - def from_tuple(amount_msat, payment_hash, cltv_abs, htlc_id, timestamp) -> 'UpdateAddHtlc': + def from_tuple(amount_msat, rhash, cltv_abs, htlc_id, timestamp) -> 'UpdateAddHtlc': return UpdateAddHtlc( amount_msat=amount_msat, - payment_hash=payment_hash, + payment_hash=bytes.fromhex(rhash), cltv_abs=cltv_abs, htlc_id=htlc_id, timestamp=timestamp) def to_json(self): - return self.amount_msat, self.payment_hash, self.cltv_abs, self.htlc_id, self.timestamp + self._validate() + return dataclasses.astuple(self) + + def _validate(self): + assert isinstance(self.amount_msat, int), self.amount_msat + assert isinstance(self.payment_hash, bytes) and len(self.payment_hash) == 32 + assert isinstance(self.cltv_abs, int) and self.cltv_abs <= NLOCKTIME_BLOCKHEIGHT_MAX, self.cltv_abs + assert isinstance(self.htlc_id, int) or self.htlc_id is None, self.htlc_id + assert isinstance(self.timestamp, int), self.timestamp + + def __post_init__(self): + self._validate() class OnionFailureCodeMetaFlag(IntFlag): diff --git a/tests/test_lnchannel.py b/tests/test_lnchannel.py index 39d8d935c..59d7f0024 100644 --- a/tests/test_lnchannel.py +++ b/tests/test_lnchannel.py @@ -27,6 +27,7 @@ import os import binascii from pprint import pformat import logging +import dataclasses from electrum import bitcoin from electrum import lnpeer @@ -257,31 +258,34 @@ class TestChannel(ElectrumTestCase): self.paymentPreimage = b"\x01" * 32 paymentHash = bitcoin.sha256(self.paymentPreimage) - self.htlc_dict = { - 'payment_hash': paymentHash, - 'amount_msat': one_bitcoin_in_msat, - 'cltv_abs': 5, - 'timestamp': 0, - } + self.htlc = UpdateAddHtlc( + payment_hash=paymentHash, + amount_msat=one_bitcoin_in_msat, + cltv_abs=5, + timestamp=0, + ) # First Alice adds the outgoing HTLC to her local channel's state # update log. Then Alice sends this wire message over to Bob who adds # this htlc to his remote state update log. - self.aliceHtlcIndex = self.alice_channel.add_htlc(self.htlc_dict).htlc_id + self.aliceHtlcIndex = self.alice_channel.add_htlc(self.htlc).htlc_id self.assertNotEqual(list(self.alice_channel.hm.htlcs_by_direction(REMOTE, RECEIVED, 1).values()), []) before = self.bob_channel.balance_minus_outgoing_htlcs(REMOTE) beforeLocal = self.bob_channel.balance_minus_outgoing_htlcs(LOCAL) - self.bobHtlcIndex = self.bob_channel.receive_htlc(self.htlc_dict).htlc_id + self.bobHtlcIndex = self.bob_channel.receive_htlc(self.htlc).htlc_id self.htlc = self.bob_channel.hm.log[REMOTE]['adds'][0] def test_concurrent_reversed_payment(self): - self.htlc_dict['payment_hash'] = bitcoin.sha256(32 * b'\x02') - self.htlc_dict['amount_msat'] += 1000 - self.bob_channel.add_htlc(self.htlc_dict) - self.alice_channel.receive_htlc(self.htlc_dict) + self.htlc = dataclasses.replace( + self.htlc, + payment_hash=bitcoin.sha256(32 * b'\x02'), + amount_msat=self.htlc.amount_msat + 1000, + ) + self.bob_channel.add_htlc(self.htlc) + self.alice_channel.receive_htlc(self.htlc) self.assertNumberNonAnchorOutputs(2, self.alice_channel.get_latest_commitment(LOCAL)) self.assertNumberNonAnchorOutputs(3, self.alice_channel.get_next_commitment(LOCAL)) @@ -561,9 +565,12 @@ class TestChannel(ElectrumTestCase): tx6 = str(alice_channel.force_close_tx()) self.assertNotEqual(tx5, tx6) - self.htlc_dict['amount_msat'] *= 5 - bob_index = bob_channel.add_htlc(self.htlc_dict).htlc_id - alice_index = alice_channel.receive_htlc(self.htlc_dict).htlc_id + self.htlc = dataclasses.replace( + self.htlc, + amount_msat=self.htlc.amount_msat * 5, + ) + bob_index = bob_channel.add_htlc(self.htlc).htlc_id + alice_index = alice_channel.receive_htlc(self.htlc).htlc_id force_state_transition(bob_channel, alice_channel) @@ -662,18 +669,26 @@ class TestChannel(ElectrumTestCase): self.alice_to_bob_fee_update(0) force_state_transition(self.alice_channel, self.bob_channel) - self.htlc_dict['payment_hash'] = bitcoin.sha256(32 * b'\x02') - self.alice_channel.add_htlc(self.htlc_dict) - self.htlc_dict['payment_hash'] = bitcoin.sha256(32 * b'\x03') - self.alice_channel.add_htlc(self.htlc_dict) + self.htlc = dataclasses.replace( + self.htlc, + payment_hash=bitcoin.sha256(32 * b'\x02'), + ) + self.alice_channel.add_htlc(self.htlc) + self.htlc = dataclasses.replace( + self.htlc, + payment_hash=bitcoin.sha256(32 * b'\x03'), + ) + self.alice_channel.add_htlc(self.htlc) # now there are three htlcs (one was in setUp) # Alice now has an available balance of 2 BTC. We'll add a new HTLC of # value 2 BTC, which should make Alice's balance negative (since she # has to pay a commitment fee). - new = dict(self.htlc_dict) - new['amount_msat'] *= 2.5 - new['payment_hash'] = bitcoin.sha256(32 * b'\x04') + new = dataclasses.replace( + self.htlc, + amount_msat=int(self.htlc.amount_msat * 2.5), + payment_hash=bitcoin.sha256(32 * b'\x04'), + ) with self.assertRaises(lnutil.PaymentFailure) as cm: self.alice_channel.add_htlc(new) self.assertIn('Not enough local balance', cm.exception.args[0]) @@ -822,14 +837,14 @@ class TestChanReserve(ElectrumTestCase): # Bob: 5.0 paymentPreimage = b"\x01" * 32 paymentHash = bitcoin.sha256(paymentPreimage) - htlc_dict = { - 'payment_hash': paymentHash, - 'amount_msat': int(.5 * one_bitcoin_in_msat), - 'cltv_abs': 5, - 'timestamp': 0, - } - self.alice_channel.add_htlc(htlc_dict) - self.bob_channel.receive_htlc(htlc_dict) + htlc = UpdateAddHtlc( + payment_hash=paymentHash, + amount_msat=int(.5 * one_bitcoin_in_msat), + cltv_abs=5, + timestamp=0, + ) + self.alice_channel.add_htlc(htlc) + self.bob_channel.receive_htlc(htlc) # Force a state transition, making sure this HTLC is considered valid # even though the channel reserves are not met. force_state_transition(self.alice_channel, self.bob_channel) @@ -847,10 +862,10 @@ class TestChanReserve(ElectrumTestCase): # Alice: 4.5 # Bob: 5.0 with self.assertRaises(lnutil.PaymentFailure): - htlc_dict['payment_hash'] = bitcoin.sha256(32 * b'\x02') - self.bob_channel.add_htlc(htlc_dict) + htlc = dataclasses.replace(htlc, payment_hash=bitcoin.sha256(32 * b'\x02')) + self.bob_channel.add_htlc(htlc) with self.assertRaises(lnutil.RemoteMisbehaving): - self.alice_channel.receive_htlc(htlc_dict) + self.alice_channel.receive_htlc(htlc) def part2(self): paymentPreimage = b"\x01" * 32 @@ -861,22 +876,22 @@ class TestChanReserve(ElectrumTestCase): # Resulting balances: # Alice: 1.5 # Bob: 9.5 - htlc_dict = { - 'payment_hash': paymentHash, - 'amount_msat': int(3.5 * one_bitcoin_in_msat), - 'cltv_abs': 5, - } - self.alice_channel.add_htlc(htlc_dict) - self.bob_channel.receive_htlc(htlc_dict) + htlc = UpdateAddHtlc( + payment_hash=paymentHash, + amount_msat=int(3.5 * one_bitcoin_in_msat), + cltv_abs=5, + ) + self.alice_channel.add_htlc(htlc) + self.bob_channel.receive_htlc(htlc) # Add a second HTLC of 1 BTC. This should fail because it will take # Alice's balance all the way down to her channel reserve, but since # she is the initiator the additional transaction fee makes her # balance dip below. - htlc_dict['amount_msat'] = one_bitcoin_in_msat + htlc = dataclasses.replace(htlc, amount_msat=one_bitcoin_in_msat) with self.assertRaises(lnutil.PaymentFailure): - self.alice_channel.add_htlc(htlc_dict) + self.alice_channel.add_htlc(htlc) with self.assertRaises(lnutil.RemoteMisbehaving): - self.bob_channel.receive_htlc(htlc_dict) + self.bob_channel.receive_htlc(htlc) def part3(self): # Add a HTLC of 2 BTC to Alice, and the settle it. @@ -885,14 +900,14 @@ class TestChanReserve(ElectrumTestCase): # Bob: 7.0 paymentPreimage = b"\x01" * 32 paymentHash = bitcoin.sha256(paymentPreimage) - htlc_dict = { - 'payment_hash': paymentHash, - 'amount_msat': int(2 * one_bitcoin_in_msat), - 'cltv_abs': 5, - 'timestamp': 0, - } - alice_idx = self.alice_channel.add_htlc(htlc_dict).htlc_id - bob_idx = self.bob_channel.receive_htlc(htlc_dict).htlc_id + htlc = UpdateAddHtlc( + payment_hash=paymentHash, + amount_msat=int(2 * one_bitcoin_in_msat), + cltv_abs=5, + timestamp=0, + ) + alice_idx = self.alice_channel.add_htlc(htlc).htlc_id + bob_idx = self.bob_channel.receive_htlc(htlc).htlc_id force_state_transition(self.alice_channel, self.bob_channel) self.check_bals(one_bitcoin_in_msat * 3 - self.alice_channel.get_next_fee(LOCAL), @@ -906,9 +921,9 @@ class TestChanReserve(ElectrumTestCase): # And now let Bob add an HTLC of 1 BTC. This will take Bob's balance # all the way down to his channel reserve, but since he is not paying # the fee this is okay. - htlc_dict['amount_msat'] = one_bitcoin_in_msat - self.bob_channel.add_htlc(htlc_dict) - self.alice_channel.receive_htlc(htlc_dict) + htlc = dataclasses.replace(htlc, amount_msat=one_bitcoin_in_msat) + self.bob_channel.add_htlc(htlc) + self.alice_channel.receive_htlc(htlc) force_state_transition(self.alice_channel, self.bob_channel) self.check_bals(one_bitcoin_in_msat * 3 \ - self.alice_channel.get_next_fee(LOCAL), @@ -943,12 +958,12 @@ class TestDust(ElectrumTestCase): # to pay for his htlc success transaction below_dust_for_bob = dust_limit_bob - 1 htlc_amt = below_dust_for_bob + success_weight * (fee_per_kw // 1000) - htlc = { - 'payment_hash': paymentHash, - 'amount_msat': 1000 * htlc_amt, - 'cltv_abs': 5, # consistent with channel policy - 'timestamp': 0, - } + htlc = UpdateAddHtlc( + payment_hash=paymentHash, + amount_msat=1000 * htlc_amt, + cltv_abs=5, # consistent with channel policy + timestamp=0, + ) # add the htlc alice_htlc_id = alice_channel.add_htlc(htlc).htlc_id