Merge pull request #10993 from spesmilo/remove_attr
What changed, and why it matters
This commit replaces the third-party 'attrs' library with Python's built-in 'dataclasses' across several Electrum source files. It is a routine refactoring change: the same data classes are defined in a different syntax, and equivalent validation/conversion logic is preserved using Python's standard mechanisms. There is no indication this fixes or introduces a security vulnerability.
No security action required. Treat as normal dependency-reduction refactoring; review for functional parity during normal QA.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The patch removes the dependency on the ‘attrs’ package and rewrites @attr.s decorated classes as @dataclasses.dataclass. It preserves behavior by adding post_init methods for converters, setattr for validators, and a repr_dataclass helper for custom repr formatting. Affected classes include BaseInvoice/Invoice/Request, PathEdge/RouteEdge/TrampolineEdge, ChannelConfig/LocalConfig/RemoteConfig, FeeUpdate, ChannelConstraints, Outpoint, SwapFees/SwapOffer/SwapData, and DBMetadata. The changes are structural and do not alter security-critical logic.
Changed components
electrum/invoices.pyelectrum/lnrouter.pyelectrum/lnutil.pyelectrum/stored_dict.pyelectrum/submarine_swaps.pyelectrum/util.pyelectrum/wallet_db.pycontrib/requirements/requirements.txtInspect captured patch +195 / −124
### contrib/requirements/requirements.txt
@@ -8,10 +8,6 @@ jsonpatch
electrum_ecc>=0.0.4,<0.1
electrum_aionostr>=0.1.0,<0.2
-# - upper limit to avoid needing hatchling at build-time :/
-# (however newer versions should work at runtime)
-attrs>=20.1.0,<23
-
# Note that we also need the dnspython[DNSSEC] extra which pulls in cryptography,
# but as that is not pure-python it cannot be listed in this file!
# - upper limit to avoid needing hatchling at build-time :/
### electrum/invoices.py
@@ -1,8 +1,7 @@
import time
from typing import TYPE_CHECKING, List, Optional, Union, Dict, Any, Sequence
from decimal import Decimal
-
-import attr
+import dataclasses
from .stored_dict import StoredObject, stored_at
from .i18n import _
@@ -92,35 +91,40 @@ def _decode_outputs(outputs) -> Optional[List[PartialTxOutput]]:
LN_EXPIRY_NEVER = 100 * 365 * 24 * 60 * 60 # 100 years
-@attr.s
+@dataclasses.dataclass(kw_only=True)
class BaseInvoice(StoredObject):
"""
Base class for Invoice and Request
In the code, we use 'invoice' for outgoing payments, and 'request' for incoming payments.
-
- TODO this class is getting too complicated for "attrs"... maybe we should rewrite it without.
"""
# mandatory fields
- amount_msat = attr.ib( # can be '!' or None
- kw_only=True, on_setattr=attr.setters.validate) # type: Optional[Union[int, str]]
- message = attr.ib(type=str, kw_only=True)
- time = attr.ib( # timestamp of the invoice
- type=int, kw_only=True, validator=attr.validators.instance_of(int), on_setattr=attr.setters.validate)
- exp = attr.ib( # expiration delay (relative). 0 means never
- type=int, kw_only=True, validator=attr.validators.instance_of(int), on_setattr=attr.setters.validate)
+ amount_msat: Optional[Union[int, str]] # can be '!' or None
+ message: str
+ time: int # timestamp of the invoice
+ exp: int # expiration delay (relative). 0 means never
# optional fields.
# an request (incoming) can be satisfied onchain, using lightning or using a swap
# an invoice (outgoing) is constructed from a source: bip21, lnaddr
# onchain only
- outputs = attr.ib(kw_only=True, converter=_decode_outputs) # type: Optional[List[PartialTxOutput]]
- height = attr.ib( # only for receiving
- type=int, kw_only=True, validator=attr.validators.instance_of(int), on_setattr=attr.setters.validate)
+ outputs: Optional[List[PartialTxOutput]]
+ height: int # only for receiving
# (unused) historical bip70 invoice data, for BIP70 invoices paid in the past
- bip70 = attr.ib(type=str, kw_only=True, default=None) # type: Optional[str]
+ bip70: Optional[str] = None
+
+ def __post_init__(self):
+ self.outputs = _decode_outputs(self.outputs) # stored as legacy tuples
+
+ def __setattr__(self, key, value):
+ # some fields are validated, at init (the generated __init__ assigns them) and on later assignments
+ if key == 'amount_msat':
+ self._validate_amount(value)
+ elif key in ('time', 'exp', 'height') and not isinstance(value, int):
+ raise TypeError(f"{key} must be an int, not {value!r}")
+ super().__setattr__(key, value)
def is_lightning(self) -> bool:
raise NotImplementedError()
@@ -194,8 +198,8 @@ def set_amount_msat(self, amount_msat: Union[int, str]) -> None:
self.outputs = [PartialTxOutput(scriptpubkey=outputs[0].scriptpubkey, value=amount_sat)]
self.amount_msat = amount_msat
- @amount_msat.validator
- def _validate_amount(self, attribute, value):
+ @staticmethod
+ def _validate_amount(value):
if value is None:
return
if isinstance(value, int):
@@ -254,12 +258,18 @@ def as_dict(self, status):
@stored_at('/invoices/*')
-@attr.s
+@dataclasses.dataclass(kw_only=True)
class Invoice(BaseInvoice):
- lightning_invoice = attr.ib(type=str, kw_only=True) # type: Optional[str]
+ lightning_invoice: Optional[str]
__lnaddr = None # type: BOLT11Addr | None
_broadcasting_status = None # can be None or PR_BROADCASTING or PR_BROADCAST
+ def __post_init__(self):
+ super().__post_init__()
+ if self.lightning_invoice is not None:
+ # this checks the str can be decoded; the result is saved, just to avoid having to recompute later
+ self.__lnaddr = decode_bolt11_invoice(self.lightning_invoice)
+
def is_lightning(self):
return self.lightning_invoice is not None
@@ -285,12 +295,6 @@ def rhash(self) -> str:
assert self.is_lightning()
return self._lnaddr.paymenthash.hex()
- @lightning_invoice.validator
- def _validate_invoice_str(self, attribute, value):
- if value is not None:
- lnaddr = decode_bolt11_invoice(value) # this checks the str can be decoded
- self.__lnaddr = lnaddr # save it, just to avoid having to recompute later
-
def can_be_paid_onchain(self) -> bool:
if self.is_lightning():
return bool(self._lnaddr.get_fallback_address()) or (bool(self.outputs))
@@ -304,9 +308,13 @@ def to_debug_json(self) -> Dict[str, Any]:
@stored_at('/payment_requests/*')
-@attr.s
+@dataclasses.dataclass(kw_only=True)
class Request(BaseInvoice):
- payment_hash = attr.ib(type=bytes, kw_only=True, converter=hex_to_bytes) # type: Optional[bytes]
+ payment_hash: Optional[bytes]
+
+ def __post_init__(self):
+ super().__post_init__()
+ self.payment_hash = hex_to_bytes(self.payment_hash) # stored as hex
def is_lightning(self):
return self.payment_hash is not None
### electrum/lnrouter.py
@@ -30,10 +30,9 @@
import threading
from threading import RLock
from math import inf
+import dataclasses
-import attr
-
-from .util import profiler, with_lock, now
+from .util import profiler, with_lock, now, repr_dataclass
from .logging import Logger
from .lnutil import (NUM_MAX_EDGES_IN_PAYMENT_PATH, ShortChannelID, LnFeatures,
NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE, PaymentFeeBudget)
@@ -61,23 +60,31 @@ def fee_for_edge_msat(forwarded_amount_msat: int, fee_base_msat: int, fee_propor
+ (forwarded_amount_msat * fee_proportional_millionths // 1_000_000)
-@attr.s(slots=True)
+@dataclasses.dataclass(slots=True, kw_only=True, repr=False)
class PathEdge:
- start_node = attr.ib(type=bytes, kw_only=True, repr=lambda val: val.hex())
- end_node = attr.ib(type=bytes, kw_only=True, repr=lambda val: val.hex())
- short_channel_id = attr.ib(type=ShortChannelID, kw_only=True, repr=lambda val: str(val))
+ start_node: bytes
+ end_node: bytes
+ short_channel_id: ShortChannelID
+
+ def __repr__(self):
+ return repr_dataclass(self, {
+ 'start_node': bytes.hex,
+ 'end_node': bytes.hex,
+ 'short_channel_id': str,
+ 'node_features': lambda val: str(int(val)), # RouteEdge
+ })
@property
def node_id(self) -> bytes:
# legacy compat # TODO rm
return self.end_node
-@attr.s
+@dataclasses.dataclass(kw_only=True, repr=False)
class RouteEdge(PathEdge):
- fee_base_msat = attr.ib(type=int, kw_only=True) # for start_node
- fee_proportional_millionths = attr.ib(type=int, kw_only=True) # for start_node
- cltv_delta = attr.ib(type=int, kw_only=True) # for start_node
- node_features = attr.ib(type=int, kw_only=True, repr=lambda val: str(int(val))) # note: for end_node!
+ fee_base_msat: int # for start_node
+ fee_proportional_millionths: int # for start_node
+ cltv_delta: int # for start_node
+ node_features: int # note: for end_node!
def fee_for_edge(self, amount_msat: int) -> int:
return fee_for_edge_msat(forwarded_amount_msat=amount_msat,
@@ -113,12 +120,12 @@ def has_feature_varonion(self) -> bool:
def is_trampoline(self) -> bool:
return False
-@attr.s
+@dataclasses.dataclass(kw_only=True, repr=False)
class TrampolineEdge(RouteEdge):
- invoice_routing_info = attr.ib(type=Sequence[bytes], default=None)
- invoice_features = attr.ib(type=int, default=None)
+ invoice_routing_info: Optional[Sequence[bytes]] = None
+ invoice_features: Optional[int] = None
# this is re-defined from parent just to specify a default value:
- short_channel_id = attr.ib(default=ShortChannelID(8), repr=lambda val: str(val))
+ short_channel_id: ShortChannelID = ShortChannelID(8)
def is_trampoline(self):
return True
### electrum/lnutil.py
@@ -15,9 +15,8 @@
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, is_hex_str
+from .util import bfh, UserFacingException, list_enabled_bits, is_hex_str, repr_dataclass
from .util import ShortID as ShortChannelID, format_short_id as format_short_channel_id
from .crypto import sha256, pw_decode_with_version_and_mac
@@ -103,34 +102,58 @@ def deserialize_htlc_key(htlc_key: str) -> Tuple[bytes, int]:
return bytes.fromhex(scid), int(htlc_id)
-@attr.s
+@dataclasses.dataclass(repr=False)
class OnlyPubkeyKeypair(StoredObject):
- pubkey = attr.ib(type=bytes, converter=hex_to_bytes, repr=bytes_to_hex)
+ pubkey: bytes
+
+ def __post_init__(self):
+ self.pubkey = hex_to_bytes(self.pubkey) # stored as hex
+
+ def __repr__(self):
+ return repr_dataclass(self, {bytes: bytes_to_hex})
-@attr.s
+@dataclasses.dataclass(repr=False)
class Keypair(OnlyPubkeyKeypair):
- privkey = attr.ib(type=bytes, converter=hex_to_bytes, repr=bytes_to_hex)
+ privkey: bytes
+
+ def __post_init__(self):
+ super().__post_init__()
+ self.privkey = hex_to_bytes(self.privkey)
-@attr.s
+@dataclasses.dataclass(repr=False)
class ChannelConfig(StoredObject):
# shared channel config fields
- payment_basepoint = attr.ib(type=OnlyPubkeyKeypair, converter=json_to_keypair)
- multisig_key = attr.ib(type=OnlyPubkeyKeypair, converter=json_to_keypair)
- htlc_basepoint = attr.ib(type=OnlyPubkeyKeypair, converter=json_to_keypair)
- delayed_basepoint = attr.ib(type=OnlyPubkeyKeypair, converter=json_to_keypair)
- revocation_basepoint = attr.ib(type=OnlyPubkeyKeypair, converter=json_to_keypair)
- to_self_delay = attr.ib(type=int) # applies to OTHER ctx
- dust_limit_sat = attr.ib(type=int) # applies to SAME ctx
- max_htlc_value_in_flight_msat = attr.ib(type=int) # max val of INCOMING htlcs
- max_accepted_htlcs = attr.ib(type=int) # max num of INCOMING htlcs
- initial_msat = attr.ib(type=int)
- reserve_sat = attr.ib(type=int) # applies to OTHER ctx
- htlc_minimum_msat = attr.ib(type=int) # smallest value for INCOMING htlc
- upfront_shutdown_script = attr.ib(type=bytes, converter=hex_to_bytes, repr=bytes_to_hex)
- announcement_node_sig = attr.ib(type=bytes, converter=hex_to_bytes, repr=bytes_to_hex)
- announcement_bitcoin_sig = attr.ib(type=bytes, converter=hex_to_bytes, repr=bytes_to_hex)
+ payment_basepoint: OnlyPubkeyKeypair
+ multisig_key: OnlyPubkeyKeypair
+ htlc_basepoint: OnlyPubkeyKeypair
+ delayed_basepoint: OnlyPubkeyKeypair
+ revocation_basepoint: OnlyPubkeyKeypair
+ to_self_delay: int # applies to OTHER ctx
+ dust_limit_sat: int # applies to SAME ctx
+ max_htlc_value_in_flight_msat: int # max val of INCOMING htlcs
+ max_accepted_htlcs: int # max num of INCOMING htlcs
+ initial_msat: int
+ reserve_sat: int # applies to OTHER ctx
+ htlc_minimum_msat: int # smallest value for INCOMING htlc
+ upfront_shutdown_script: bytes
+ announcement_node_sig: bytes
+ announcement_bitcoin_sig: bytes
+
+ def __post_init__(self):
+ # keypairs are stored as dicts, and bytes as hex
+ self.payment_basepoint = json_to_keypair(self.payment_basepoint)
+ self.multisig_key = json_to_keypair(self.multisig_key)
+ self.htlc_basepoint = json_to_keypair(self.htlc_basepoint)
+ self.delayed_basepoint = json_to_keypair(self.delayed_basepoint)
+ self.revocation_basepoint = json_to_keypair(self.revocation_basepoint)
+ self.upfront_shutdown_script = hex_to_bytes(self.upfront_shutdown_script)
+ self.announcement_node_sig = hex_to_bytes(self.announcement_node_sig)
+ self.announcement_bitcoin_sig = hex_to_bytes(self.announcement_bitcoin_sig)
+
+ def __repr__(self):
+ return repr_dataclass(self, {bytes: bytes_to_hex})
def validate_params(self, *, funding_sat: int, config: 'SimpleConfig', peer_features: 'LnFeatures') -> None:
conf_name = type(self).__name__
@@ -233,13 +256,20 @@ def cross_validate_params(
@stored_at('/channels/*/local_config')
-@attr.s
+@dataclasses.dataclass(repr=False)
class LocalConfig(ChannelConfig):
- channel_seed = attr.ib(type=bytes, converter=hex_to_bytes, repr=bytes_to_hex) # type: Optional[bytes]
- funding_locked_received = attr.ib(type=bool)
- current_commitment_signature = attr.ib(type=bytes, converter=hex_to_bytes, repr=bytes_to_hex)
- current_htlc_signatures = attr.ib(type=bytes, converter=hex_to_bytes, repr=bytes_to_hex)
- per_commitment_secret_seed = attr.ib(type=bytes, converter=hex_to_bytes, repr=bytes_to_hex)
+ channel_seed: Optional[bytes]
+ funding_locked_received: bool
+ current_commitment_signature: bytes
+ current_htlc_signatures: bytes
+ per_commitment_secret_seed: bytes
+
+ def __post_init__(self):
+ super().__post_init__()
+ self.channel_seed = hex_to_bytes(self.channel_seed)
+ self.current_commitment_signature = hex_to_bytes(self.current_commitment_signature)
+ self.current_htlc_signatures = hex_to_bytes(self.current_htlc_signatures)
+ self.per_commitment_secret_seed = hex_to_bytes(self.per_commitment_secret_seed)
@classmethod
def from_seed(cls, **kwargs):
@@ -298,27 +328,35 @@ def validate_params(self, *, funding_sat: int, config: 'SimpleConfig', peer_feat
@stored_at('/channels/*/remote_config')
-@attr.s
+@dataclasses.dataclass(repr=False)
class RemoteConfig(ChannelConfig):
- next_per_commitment_point = attr.ib(type=bytes, converter=hex_to_bytes, repr=bytes_to_hex)
- current_per_commitment_point = attr.ib(default=None, type=bytes, converter=hex_to_bytes, repr=bytes_to_hex)
+ next_per_commitment_point: bytes
+ current_per_commitment_point: Optional[bytes] = None
+
+ def __post_init__(self):
+ super().__post_init__()
+ self.next_per_commitment_point = hex_to_bytes(self.next_per_commitment_point)
+ self.current_per_commitment_point = hex_to_bytes(self.current_per_commitment_point)
@stored_at('/channels/*/log/*/fee_updates/*')
-@attr.s
+@dataclasses.dataclass
class FeeUpdate(StoredObject):
- rate = attr.ib(type=int) # in sat/kw
- ctn_local = attr.ib(default=None, type=int)
- ctn_remote = attr.ib(default=None, type=int)
+ rate: int # in sat/kw
+ ctn_local: Optional[int] = None
+ ctn_remote: Optional[int] = None
@stored_at('/channels/*/constraints')
-@attr.s
+@dataclasses.dataclass
class ChannelConstraints(StoredObject):
- flags = attr.ib(type=int, converter=int)
- capacity = attr.ib(type=int) # in sat
- is_initiator = attr.ib(type=bool) # note: sometimes also called "funder"
- funding_txn_minimum_depth = attr.ib(type=int)
+ flags: int
+ capacity: int # in sat
+ is_initiator: bool # note: sometimes also called "funder"
+ funding_txn_minimum_depth: int
+
+ def __post_init__(self):
+ self.flags = int(self.flags)
CHANNEL_BACKUP_VERSION_LATEST = 3
@@ -500,10 +538,10 @@ class ScriptHtlc(NamedTuple):
# FIXME duplicate of TxOutpoint in transaction.py??
@stored_at('/channels/*/funding_outpoint')
-@attr.s
+@dataclasses.dataclass
class Outpoint(StoredObject):
- txid = attr.ib(type=str)
- output_index = attr.ib(type=int)
+ txid: str
+ output_index: int
def to_str(self):
return "{}:{}".format(self.txid, self.output_index)
### electrum/stored_dict.py
@@ -166,7 +166,7 @@ def db_remove(self, key: _FLEX_KEY) -> None:
class StoredObject(BaseStoredObject):
- """for attr.s objects """
+ """for dataclasses"""
def __setattr__(self, key: str, value):
assert isinstance(key, str), repr(key)
### electrum/submarine_swaps.py
@@ -8,8 +8,8 @@
from decimal import Decimal
import math
import time
+import dataclasses
-import attr
import aiohttp
from electrum_ecc import ECPrivkey
@@ -182,44 +182,51 @@ def __str__(self):
return _("The swap server errored or is unreachable.")
-@attr.s(frozen=True)
+@dataclasses.dataclass(frozen=True)
class SwapFees:
- percentage = attr.ib(type=Decimal)
- mining_fee = attr.ib(type=int)
- min_amount = attr.ib(type=int)
- max_forward = attr.ib(type=int)
- max_reverse = attr.ib(type=int)
+ percentage: Decimal
+ mining_fee: int
+ min_amount: int
+ max_forward: int
+ max_reverse: int
-@attr.frozen
+@dataclasses.dataclass(frozen=True)
class SwapOffer:
- pairs = attr.ib(type=SwapFees)
- relays = attr.ib(type=list[str])
- pow_bits = attr.ib(type=int)
- server_pubkey = attr.ib(type=str)
- timestamp = attr.ib(type=int)
+ pairs: SwapFees
+ relays: list[str]
+ pow_bits: int
+ server_pubkey: str
+ timestamp: int
@property
def server_npub(self):
return to_nip19('npub', self.server_pubkey)
@stored_at('/submarine_swaps/*')
-@attr.s
+@dataclasses.dataclass
class SwapData(StoredObject):
- is_reverse = attr.ib(type=bool) # for whoever is running code (PoV of client or server)
- locktime = attr.ib(type=int) # onchain, abs
- onchain_amount = attr.ib(type=int) # in sats
- lightning_amount = attr.ib(type=int) # in sats
- redeem_script = attr.ib(type=bytes, converter=hex_to_bytes)
- preimage = attr.ib(type=Optional[bytes], converter=hex_to_bytes)
- prepay_hash = attr.ib(type=Optional[bytes], converter=hex_to_bytes)
- privkey = attr.ib(type=bytes, converter=hex_to_bytes)
- lockup_address = attr.ib(type=str)
- claim_to_output = attr.ib(type=Optional[Tuple[str, int]]) # address, amount to claim the funding utxo to
- funding_txid = attr.ib(type=Optional[str])
- spending_txid = attr.ib(type=Optional[str])
- is_redeemed = attr.ib(type=bool)
+ is_reverse: bool # for whoever is running code (PoV of client or server)
+ locktime: int # onchain, abs
+ onchain_amount: int # in sats
+ lightning_amount: int # in sats
+ redeem_script: bytes
+ preimage: Optional[bytes]
+ prepay_hash: Optional[bytes]
+ privkey: bytes
+ lockup_address: str
+ claim_to_output: Optional[Tuple[str, int]] # address, amount to claim the funding utxo to
+ funding_txid: Optional[str]
+ spending_txid: Optional[str]
+ is_redeemed: bool
+
+ def __post_init__(self):
+ # bytes are stored as hex
+ self.redeem_script = hex_to_bytes(self.redeem_script)
+ self.preimage = hex_to_bytes(self.preimage)
+ self.prepay_hash = hex_to_bytes(self.prepay_hash)
+ self.privkey = hex_to_bytes(self.privkey)
_funding_prevout = None # type: Optional[TxOutpoint] # for RBF
_payment_hash = None
### electrum/util.py
@@ -22,6 +22,7 @@
# SOFTWARE.
import concurrent.futures
import copy
+import dataclasses
from dataclasses import dataclass
import logging
import os
@@ -455,6 +456,21 @@ def json_decode(x):
return x
+def repr_dataclass(obj, formatters: Dict[Union[str, type], Callable[[Any], str]]) -> str:
+ """repr of a dataclass instance, with a custom formatting of some fields (like the
+ 'repr' parameter of attrs). formatters is keyed by field name, or by the type of a value.
+ None values are never formatted.
+ """
+ parts = []
+ for f in dataclasses.fields(obj):
+ if not f.repr:
+ continue
+ value = getattr(obj, f.name)
+ fmt = None if value is None else formatters.get(f.name) or formatters.get(type(value))
+ parts.append(f"{f.name}={fmt(value) if fmt else repr(value)}")
+ return f"{type(obj).__name__}({', '.join(parts)})"
+
+
def json_normalize(x):
# note: The return value of commands, when going through the JSON-RPC interface,
# is json-encoded. The encoder used there cannot handle some types, e.g. electrum.util.Satoshis.
### electrum/wallet_db.py
@@ -30,8 +30,7 @@
Union, AbstractSet)
import time
from functools import partial
-
-import attr
+import dataclasses
from . import bitcoin
from . import constants
@@ -85,10 +84,10 @@ class TxFeesValue(NamedTuple):
@stored_at('/db_metadata')
-@attr.s
+@dataclasses.dataclass
class DBMetadata(StoredObject):
- creation_timestamp = attr.ib(default=None, type=int)
- first_electrum_version_used = attr.ib(default=None, type=str)
+ creation_timestamp: Optional[int] = None
+ first_electrum_version_used: Optional[str] = None
def to_str(self) -> str:
ts = self.creation_timestampWhy this scored 12/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.