What changed, and why it matters
This commit is a routine internal refactoring: it converts a Lightning Network data structure called UpdateAddHtlc from using the third-party 'attr' library to Python's built-in 'dataclasses'. It also removes a legacy dictionary-to-object conversion path and adds validation checks when creating these objects. There is no security fix or vulnerability here.
No security action required. Treat as normal code maintenance; review the new validation invariants for correctness during regular QA.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The patch refactors UpdateAddHtlc in electrum/lnutil.py from an @attr.s(frozen=True) class to a @dataclasses.dataclass(frozen=True, kw_only=True). It removes the dict-constructor fallback in Channel.add_htlc/receive_htlc, replaces attr.evolve with dataclasses.replace, and adds a post_init/_validate method enforcing type and length invariants (payment_hash is 32 bytes, cltv_abs <= NLOCKTIME_BLOCKHEIGHT_MAX, etc.). The from_tuple storage helper now explicitly converts the payment hash from hex to bytes. Test files are updated to construct UpdateAddHtlc objects directly instead of passing dicts.
Changed components
electrum/lnutil.pyelectrum/lnchannel.pytests/test_lnchannel.pyInspect captured patch +102 / −77
diff --git a/electrum/lnchannel.py b/electrum/lnchannel.py
index b6e1964..2d57a4a 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 3903f69..11cee64 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 39d8d93..59d7f00 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
Why this scored 15/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.