What changed, and why it matters
This commit is a pure internal refactoring of how Electrum marks which Python classes correspond to which parts of the wallet database. It replaces two old decorators (`stored_in` and `stored_as`) with a single new decorator (`stored_at`) and updates the registration tables that convert database keys and values. There is no user-facing change, no new feature, and no obvious security fix or vulnerability introduced in the diff itself.
No security action required. Treat as normal code maintenance. If reviewing for correctness, verify that every old `stored_in`/`stored_as` registration has an equivalent `stored_at` registration with the correct wildcard depth and that key-conversion registrations in `wallet_db.py` preserve the old behavior (e.g., `register_key(key+'/*', ...)` for `locked_in`, `fails`, `settles`).
Security signals we found
No security-relevant keywords in commit title or message
No changes to cryptographic operations, network handling, or user input parsing
Refactor-only: decorator rename and registry restructuring
No added assertions, bounds checks, or input validation changes
No references to CVEs, advisories, or security issues in commit or supplied references
Evidence from the diff
The change unifies the stored_in and stored_as decorators into stored_at, supporting path patterns such as invoices/* and fee_updates/*. It rewrites stored_dict.py to use nested defaultdict registries (registered_names, registered_keys) keyed by path depth, and moves _convert_dict_key and _convert_dict_value from json_db.py into stored_dict.py. Call sites across invoices.py, lnutil.py, submarine_swaps.py, and wallet_db.py are updated to the new decorator syntax. The diff shows a mechanical refactor with matching before/after semantics (e.g., stored_in('fee_updates') → stored_at('fee_updates/*'), stored_as('constraints') → stored_at('constraints')). No cryptographic, network, or serialization behavior changes are visible.
Changed components
electrum/stored_dict.pyelectrum/json_db.pyelectrum/wallet_db.pyelectrum/invoices.pyelectrum/lnutil.pyelectrum/submarine_swaps.pyelectrum/lnworker.pyInspect captured patch +82 / −82
diff --git a/electrum/invoices.py b/electrum/invoices.py
index deff7a1..7d6a8bb 100644
--- a/electrum/invoices.py
+++ b/electrum/invoices.py
@@ -4,7 +4,7 @@ from decimal import Decimal
import attr
-from .stored_dict import StoredObject, stored_in
+from .stored_dict import StoredObject, stored_at
from .i18n import _
from .util import age, InvoiceError, format_satoshis
from .bip21 import create_bip21_uri
@@ -253,7 +253,7 @@ class BaseInvoice(StoredObject):
return d
-@stored_in('invoices')
+@stored_at('invoices/*')
@attr.s
class Invoice(BaseInvoice):
lightning_invoice = attr.ib(type=str, kw_only=True) # type: Optional[str]
@@ -303,7 +303,7 @@ class Invoice(BaseInvoice):
return d
-@stored_in('payment_requests')
+@stored_at('payment_requests/*')
@attr.s
class Request(BaseInvoice):
payment_hash = attr.ib(type=bytes, kw_only=True, converter=hex_to_bytes) # type: Optional[bytes]
diff --git a/electrum/json_db.py b/electrum/json_db.py
index 856e4fd..e7d78f9 100644
--- a/electrum/json_db.py
+++ b/electrum/json_db.py
@@ -33,7 +33,7 @@ import jsonpointer
from . import util
from .util import WalletFileException, profiler, sticky_property
from .logging import Logger
-from .stored_dict import StoredDict, _FLEX_KEY, registered_names, registered_dicts, registered_dict_keys, registered_parent_keys
+from .stored_dict import StoredDict, _FLEX_KEY, registered_names, registered_keys, _convert_dict_key, _convert_dict_value
if TYPE_CHECKING:
@@ -249,40 +249,11 @@ class JsonDB(Logger):
def _should_convert_to_stored_dict(self, key) -> bool:
return True
- def _convert_dict_key(self, path: List[str]) -> _FLEX_KEY:
- """Maybe convert key from str to python type (typically int or IntEnum)"""
- assert all(isinstance(x, str) for x in path), repr(path)
- key = path[-1]
- parent_key = path[-2] if len(path) > 1 else None
- gp_key = path[-3] if len(path) > 2 else None
- if parent_key and parent_key in registered_dict_keys:
- convert_key = registered_dict_keys[parent_key]
- elif gp_key and gp_key in registered_parent_keys:
- convert_key = registered_parent_keys.get(gp_key)
- else:
- convert_key = None
- if convert_key:
- key = convert_key(key)
- assert isinstance(key, _FLEX_KEY), f"unexpected type for {key=!r} at {path=}"
- return key
+ def _convert_dict_key(self, path: List[str], key: str) -> _FLEX_KEY:
+ return _convert_dict_key(path, key)
def _convert_dict_value(self, path: List[str], v) -> Any:
- assert all(isinstance(x, str) for x in path), repr(path)
- key = path[-1]
- if key in registered_dicts:
- constructor, _type = registered_dicts[key]
- if _type == dict:
- v = dict((k, constructor(**x)) for k, x in v.items())
- elif _type == tuple:
- v = dict((k, constructor(*x)) for k, x in v.items())
- else:
- v = dict((k, constructor(x)) for k, x in v.items())
- elif key in registered_names:
- constructor, _type = registered_names[key]
- if _type == dict:
- v = constructor(**v)
- else:
- v = constructor(v)
+ v = _convert_dict_value(path, v)
if isinstance(v, dict):
v = self._convert_dict(path, v)
return v
@@ -293,7 +264,7 @@ class JsonDB(Logger):
d = {}
for k, v in list(data.items()):
child_path = path + [k]
- k = self._convert_dict_key(child_path)
+ k = self._convert_dict_key(path, k)
v = self._convert_dict_value(child_path, v)
d[k] = v
return d
diff --git a/electrum/lnutil.py b/electrum/lnutil.py
index 56b620a..d46b2d4 100644
--- a/electrum/lnutil.py
+++ b/electrum/lnutil.py
@@ -29,7 +29,7 @@ from .bip32 import BIP32Node, BIP32_PRIME
from .transaction import BCDataStream, OPPushDataGeneric
from .logging import get_logger
from .fee_policy import FEERATE_PER_KW_MIN_RELAY_LIGHTNING
-from .stored_dict import StoredObject, stored_in, stored_as
+from .stored_dict import StoredObject, stored_at
if TYPE_CHECKING:
@@ -214,7 +214,7 @@ class ChannelConfig(StoredObject):
raise Exception(f"feerate lower than min relay fee. {initial_feerate_per_kw} sat/kw.")
-@stored_as('local_config')
+@stored_at('local_config')
@attr.s
class LocalConfig(ChannelConfig):
channel_seed = attr.ib(type=bytes, converter=hex_to_bytes, repr=bytes_to_hex) # type: Optional[bytes]
@@ -267,14 +267,14 @@ class LocalConfig(ChannelConfig):
raise Exception(f"{conf_name}. htlc_minimum_msat too low: {self.htlc_minimum_msat} msat < {HTLC_MINIMUM_MSAT_MIN}")
-@stored_as('remote_config')
+@stored_at('remote_config')
@attr.s
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)
-@stored_in('fee_updates')
+@stored_at('fee_updates/*')
@attr.s
class FeeUpdate(StoredObject):
rate = attr.ib(type=int) # in sat/kw
@@ -282,7 +282,7 @@ class FeeUpdate(StoredObject):
ctn_remote = attr.ib(default=None, type=int)
-@stored_as('constraints')
+@stored_at('constraints')
@attr.s
class ChannelConstraints(StoredObject):
flags = attr.ib(type=int, converter=int)
@@ -311,13 +311,13 @@ class ChannelBackupStorage(StoredObject):
return chan_id
-@stored_in('onchain_channel_backups')
+@stored_at('onchain_channel_backups/*')
@attr.s
class OnchainChannelBackupStorage(ChannelBackupStorage):
node_id_prefix = attr.ib(type=bytes, converter=hex_to_bytes) # remote node pubkey
-@stored_in('imported_channel_backups')
+@stored_at('imported_channel_backups/*')
@attr.s
class ImportedChannelBackupStorage(ChannelBackupStorage):
node_id = attr.ib(type=bytes, converter=hex_to_bytes) # remote node pubkey
@@ -413,7 +413,7 @@ class ScriptHtlc(NamedTuple):
# FIXME duplicate of TxOutpoint in transaction.py??
-@stored_as('funding_outpoint')
+@stored_at('funding_outpoint')
@attr.s
class Outpoint(StoredObject):
txid = attr.ib(type=str)
@@ -596,7 +596,7 @@ class ShachainElement(NamedTuple):
def __str__(self):
return "ShachainElement(" + self.secret.hex() + "," + str(self.index) + ")"
- @stored_in('buckets', tuple)
+ @stored_at('buckets/*', tuple)
def read(*x):
return ShachainElement(bfh(x[0]), int(x[1]))
@@ -1642,7 +1642,7 @@ class LnFeatures(IntFlag):
return hex(self._value_)
-@stored_as('channel_type', _type=None)
+@stored_at('channel_type', _type=None)
class ChannelType(IntFlag):
OPTION_LEGACY_CHANNEL = 0
OPTION_STATIC_REMOTEKEY = 1 << 12
@@ -1931,7 +1931,7 @@ class UpdateAddHtlc:
timestamp: int = dataclasses.field(default_factory=lambda: int(time.time()))
@staticmethod
- @stored_in('adds', tuple)
+ @stored_at('adds/*', tuple)
def from_tuple(amount_msat, rhash, cltv_abs, htlc_id, timestamp) -> 'UpdateAddHtlc':
return UpdateAddHtlc(
amount_msat=amount_msat,
@@ -2028,7 +2028,7 @@ class ReceivedMPPStatus(NamedTuple):
return payment_hash
@staticmethod
- @stored_in('received_mpp_htlcs', tuple)
+ @stored_at('received_mpp_htlcs/*', tuple)
def from_tuple(resolution, htlc_list, parent_set_key=None) -> 'ReceivedMPPStatus':
assert isinstance(resolution, int)
htlc_set = frozenset(ReceivedMPPHtlc.from_tuple(*htlc_data) for htlc_data in htlc_list)
diff --git a/electrum/lnworker.py b/electrum/lnworker.py
index 92ae704..41447c6 100644
--- a/electrum/lnworker.py
+++ b/electrum/lnworker.py
@@ -31,7 +31,6 @@ from aiorpcx import run_in_thread, NetAddress, ignore_after
from .logging import Logger
from .i18n import _
-from .stored_dict import stored_in
from .channel_db import UpdateStatus, ChannelDBNotLoaded, get_mychannel_info, get_mychannel_policy
from . import constants, util, lnutil
diff --git a/electrum/stored_dict.py b/electrum/stored_dict.py
index 6b377c3..08810c3 100644
--- a/electrum/stored_dict.py
+++ b/electrum/stored_dict.py
@@ -25,6 +25,7 @@
import threading
import json
+from collections import defaultdict
from typing import TYPE_CHECKING, Optional, Sequence, List, Union, Any
@@ -42,40 +43,67 @@ def locked(func):
return wrapper
-registered_names = {}
-registered_dicts = {}
-registered_dict_keys = {}
-registered_parent_keys = {}
+registered_names = defaultdict(dict)
+registered_keys = defaultdict(dict)
-def register_dict(name, method, _type):
- registered_dicts[name] = method, _type
+def _parse_path(path):
+ path2 = path.split('/')
+ name, suffix = path2[0], path2[1:]
+ n = len(suffix)
+ assert suffix == n * ['*']
+ return name, n
-def register_name(name, method, _type):
- registered_names[name] = method, _type
+def register_name(path, _type, func):
+ name, n = _parse_path(path)
+ registered_names[name][n] = _type, func
-def register_dict_key(name, method):
- registered_dict_keys[name] = method
+def register_key(path, func):
+ name, n = _parse_path(path)
+ registered_keys[name][n + 1] = func
-def register_parent_key(name, method):
- registered_parent_keys[name] = method
-def stored_as(name, _type=dict):
+def stored_at(path, _type=dict):
""" decorator that indicates the storage key of a stored object"""
def decorator(func):
- registered_names[name] = func, _type
- return func
- return decorator
-
-def stored_in(name, _type=dict):
- """ decorator that indicates the storage key of an element in a StoredDict"""
- def decorator(func):
- registered_dicts[name] = func, _type
+ register_name(path, _type, func)
return func
return decorator
_FLEX_KEY = str | int | None
+def _convert_dict_key(path: List[str], key: str) -> _FLEX_KEY:
+ """Maybe convert key from str to python type (typically int or IntEnum)"""
+ assert all(isinstance(x, str) for x in path), repr(path)
+ n = len(path)
+ for i, name in enumerate(path):
+ if name in registered_keys:
+ func = registered_keys[name].get(n - i)
+ if func:
+ key = func(key)
+ break
+ assert isinstance(key, _FLEX_KEY), f"unexpected type for {key=!r} at {path=}"
+ return key
+
+def _convert_dict_value(path: List[str], v) -> Any:
+ assert all(isinstance(x, str) for x in path), repr(path)
+ n = len(path)
+ for i, key in enumerate(path):
+ if key in registered_names:
+ reg = registered_names[key].get(n - i - 1)
+ if reg:
+ _type, constructor = reg
+ if _type == dict:
+ v = constructor(**v)
+ elif _type == tuple:
+ v = constructor(*v)
+ else:
+ v = constructor(v)
+ break
+ return v
+
+
+
class BaseStoredObject:
_db: 'JsonDB' = None
diff --git a/electrum/submarine_swaps.py b/electrum/submarine_swaps.py
index 9b84932..f83d653 100644
--- a/electrum/submarine_swaps.py
+++ b/electrum/submarine_swaps.py
@@ -39,7 +39,7 @@ from .util import (
from . import lnutil
from .lnutil import hex_to_bytes, REDEEM_AFTER_DOUBLE_SPENT_DELAY, Keypair
from .bolt11 import decode_bolt11_invoice
-from .stored_dict import StoredObject, stored_in
+from .stored_dict import StoredObject, stored_at
from . import constants
from .address_synchronizer import (TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE, TX_HEIGHT_UNCONFIRMED,
TX_HEIGHT_UNCONF_PARENT)
@@ -188,7 +188,7 @@ class SwapOffer:
return to_nip19('npub', self.server_pubkey)
-@stored_in('submarine_swaps')
+@stored_at('submarine_swaps/*')
@attr.s
class SwapData(StoredObject):
is_reverse = attr.ib(type=bool) # for whoever is running code (PoV of client or server)
diff --git a/electrum/wallet_db.py b/electrum/wallet_db.py
index 4a0fc07..c53c355 100644
--- a/electrum/wallet_db.py
+++ b/electrum/wallet_db.py
@@ -43,7 +43,7 @@ from .logging import Logger
from .lnutil import HTLCOwner, ChannelType, RecvMPPResolution
from .json_db import JsonDB, locked, modifier
from . import stored_dict
-from .stored_dict import StoredObject, stored_in, stored_as
+from .stored_dict import StoredObject, stored_at, register_key, register_name
from .plugin import run_hook, plugin_loaders
from .version import ELECTRUM_VERSION
from .i18n import _
@@ -75,14 +75,14 @@ FINAL_SEED_VERSION = 71 # electrum >= 2.7 will set this to prevent
# old versions from overwriting new format
-@stored_in('tx_fees', tuple)
+@stored_at('tx_fees/*', tuple)
class TxFeesValue(NamedTuple):
fee: Optional[int] = None
is_calculated_by_us: bool = False
num_inputs: Optional[int] = None
-@stored_as('db_metadata')
+@stored_at('db_metadata')
@attr.s
class DBMetadata(StoredObject):
creation_timestamp = attr.ib(default=None, type=int)
@@ -104,18 +104,20 @@ class WalletFileExceptionVersion51(WalletFileException): pass
# register dicts that require value conversions not handled by constructor
-stored_dict.register_dict('transactions', lambda x: tx_from_any(x, deserialize=False), None)
-stored_dict.register_dict('data_loss_protect_remote_pcp', lambda x: bytes.fromhex(x), None)
-stored_dict.register_dict('contacts', tuple, None)
+register_name('transactions/*', None, lambda x: tx_from_any(x, deserialize=False))
+register_name('data_loss_protect_remote_pcp/*', None, lambda x: bytes.fromhex(x))
+# register tuples, otherwise they will default to StoredList
+register_name('contacts/*', None, tuple)
+register_name('lightning_preimages/*', None, tuple)
# register dicts that require key conversion
for key in [
'adds', 'locked_in', 'settles', 'fails', 'fee_updates', 'buckets',
'unacked_updates', 'unfulfilled_htlcs', 'onion_keys']:
- stored_dict.register_dict_key(key, int)
+ register_key(key, int)
for key in ['log']:
- stored_dict.register_dict_key(key, lambda x: HTLCOwner(int(x)))
+ register_key(key, lambda x: HTLCOwner(int(x)))
for key in ['locked_in', 'fails', 'settles']:
- stored_dict.register_parent_key(key, lambda x: HTLCOwner(int(x)))
+ register_key(key+'/*', lambda x: HTLCOwner(int(x)))
class WalletDBUpgrader(Logger):
Why 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.