Modify the 'stored_at' syntax, so that it includes the full path
What changed, and why it matters
This commit changes how Electrum tracks where certain data objects live in the wallet database. It switches from short names like 'invoices/*' to full paths like '/invoices/*', and updates the internal registry to handle these full paths. The stated goal is to prevent naming collisions and to enable future performance improvements. There is no direct evidence in the commit that this fixes an active security vulnerability, but path-collision bugs in serialization code can sometimes lead to wrong data being read or written, which could have security implications.
Treat as a routine refactoring/robustness improvement. Reviewers should verify that the new path-walking logic correctly handles all registered paths and that no previously registered collision cases now resolve to a different constructor or key converter. No immediate security response is indicated by the available evidence.
Security signals we found
Serialization/deserialization path registry changed from flat to hierarchical
Commit message explicitly mentions collision prevention
No explicit security claim or CVE reference in commit or supplied references
No input validation or boundary changes visible in the diff
Backport from another branch suggests refactoring rather than emergency fix
Evidence from the diff
The patch modifies the stored_at decorator paths throughout Electrum to use absolute-style paths (e.g., ‘/channels/*/local_config’ instead of ‘local_config’). It rewrites stored_dict.py so that registered_names and registered_keys are nested dictionaries rather than flat defaultdict(dict) structures, and introduces _register_key_or_name and _walk_path helpers. The conversion logic now walks the full path to find the correct registered constructor or key converter. This is described as a backport from a levelDB branch and a performance/collision-avoidance change, not a security fix.
Changed components
electrum/stored_dict.pyelectrum/invoices.pyelectrum/lnutil.pyelectrum/submarine_swaps.pyelectrum/wallet_db.pyInspect captured patch +71 / −58
diff --git a/electrum/invoices.py b/electrum/invoices.py
index 7d6a8bb..55ad4fc 100644
--- a/electrum/invoices.py
+++ b/electrum/invoices.py
@@ -253,7 +253,7 @@ class BaseInvoice(StoredObject):
return d
-@stored_at('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_at('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/lnutil.py b/electrum/lnutil.py
index 9287744..8baff61 100644
--- a/electrum/lnutil.py
+++ b/electrum/lnutil.py
@@ -214,7 +214,7 @@ class ChannelConfig(StoredObject):
raise Exception(f"feerate lower than min relay fee. {initial_feerate_per_kw} sat/kw.")
-@stored_at('local_config')
+@stored_at('/channels/*/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_at('remote_config')
+@stored_at('/channels/*/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_at('fee_updates/*')
+@stored_at('/channels/*/log/*/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_at('constraints')
+@stored_at('/channels/*/constraints')
@attr.s
class ChannelConstraints(StoredObject):
flags = attr.ib(type=int, converter=int)
@@ -311,13 +311,13 @@ class ChannelBackupStorage(StoredObject):
return chan_id
-@stored_at('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_at('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_at('funding_outpoint')
+@stored_at('/channels/*/funding_outpoint')
@attr.s
class Outpoint(StoredObject):
txid = attr.ib(type=str)
@@ -598,7 +598,7 @@ class ShachainElement(NamedTuple):
def __str__(self):
return "ShachainElement(" + self.secret.hex() + "," + str(self.index) + ")"
- @stored_at('buckets/*', tuple)
+ @stored_at('/channels/*/revocation_store/buckets/*', tuple)
def read(*x):
return ShachainElement(bfh(x[0]), int(x[1]))
@@ -1644,7 +1644,7 @@ class LnFeatures(IntFlag):
return hex(self._value_)
-@stored_at('channel_type', _type=None)
+@stored_at('/channels/*/channel_type', _type=None)
class ChannelType(IntFlag):
OPTION_LEGACY_CHANNEL = 0
OPTION_STATIC_REMOTEKEY = 1 << 12
@@ -1933,7 +1933,7 @@ class UpdateAddHtlc:
timestamp: int = dataclasses.field(default_factory=lambda: int(time.time()))
@staticmethod
- @stored_at('adds/*', tuple)
+ @stored_at('/channels/*/log/*/adds/*', tuple)
def from_tuple(amount_msat, rhash, cltv_abs, htlc_id, timestamp) -> 'UpdateAddHtlc':
return UpdateAddHtlc(
amount_msat=amount_msat,
@@ -2030,7 +2030,7 @@ class ReceivedMPPStatus(NamedTuple):
return payment_hash
@staticmethod
- @stored_at('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/stored_dict.py b/electrum/stored_dict.py
index 08810c3..bbf0b95 100644
--- a/electrum/stored_dict.py
+++ b/electrum/stored_dict.py
@@ -43,23 +43,25 @@ def locked(func):
return wrapper
-registered_names = defaultdict(dict)
-registered_keys = defaultdict(dict)
-
-def _parse_path(path):
- path2 = path.split('/')
- name, suffix = path2[0], path2[1:]
- n = len(suffix)
- assert suffix == n * ['*']
- return name, n
+registered_names = {}
+registered_keys = {}
+
+def _register_key_or_name(d: dict, path_str: str, value):
+ assert path_str.startswith('/')
+ path = path_str[1:].split('/')
+ path, key = path[0:-1], path[-1]
+ for k in path:
+ if k not in d:
+ d[k] = {}
+ d = d[k]
+ d[key] = value
def register_name(path, _type, func):
- name, n = _parse_path(path)
- registered_names[name][n] = _type, func
+ _register_key_or_name(registered_names, path, (_type, func))
def register_key(path, func):
- name, n = _parse_path(path)
- registered_keys[name][n + 1] = func
+ _register_key_or_name(registered_keys, path + '/' + 'self', func)
+
def stored_at(path, _type=dict):
@@ -71,35 +73,37 @@ def stored_at(path, _type=dict):
_FLEX_KEY = str | int | None
+def _walk_path(d, path):
+ for k in path:
+ if k in d:
+ d = d[k]
+ elif '*' in d:
+ d = d['*']
+ else:
+ return None
+ return d
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
+ r = _walk_path(registered_keys, path)
+ if r:
+ if func := r.get('self'):
+ key = func(key)
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
+ r = _walk_path(registered_names, path)
+ if r and type(r) is tuple:
+ _type, constructor = r
+ if _type == dict:
+ v = constructor(**v)
+ elif _type == tuple:
+ v = constructor(*v)
+ else:
+ v = constructor(v)
return v
diff --git a/electrum/submarine_swaps.py b/electrum/submarine_swaps.py
index 863be00..f297497 100644
--- a/electrum/submarine_swaps.py
+++ b/electrum/submarine_swaps.py
@@ -185,7 +185,7 @@ class SwapOffer:
return to_nip19('npub', self.server_pubkey)
-@stored_at('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 ab3f6af..2bd6936 100644
--- a/electrum/wallet_db.py
+++ b/electrum/wallet_db.py
@@ -75,14 +75,14 @@ FINAL_SEED_VERSION = 71 # electrum >= 2.7 will set this to prevent
# old versions from overwriting new format
-@stored_at('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_at('db_metadata')
+@stored_at('/db_metadata')
@attr.s
class DBMetadata(StoredObject):
creation_timestamp = attr.ib(default=None, type=int)
@@ -104,20 +104,29 @@ class WalletFileExceptionVersion51(WalletFileException): pass
# register dicts that require value conversions not handled by constructor
-register_name('transactions/*', None, lambda x: tx_from_any(x, deserialize=False, sanitize=False))
-register_name('data_loss_protect_remote_pcp/*', None, lambda x: bytes.fromhex(x))
+register_name('/transactions/*', None, lambda x: tx_from_any(x, deserialize=False, sanitize=False))
+register_name('/channels/*/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_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']:
+ '/channels/*/log/*/adds',
+ '/channels/*/log/*/locked_in',
+ '/channels/*/log/*/settles',
+ '/channels/*/log/*/fails',
+ '/channels/*/log/*/fee_updates',
+ '/channels/*/revocation_store/buckets',
+ '/channels/*/log/*/unacked_updates',
+ '/channels/*/unfulfilled_htlcs',
+ '/channels/*/onion_keys']:
register_key(key, int)
-for key in ['log']:
+for key in [
+ '/channels/*/log',
+ '/channels/*/log/*/locked_in/*',
+ '/channels/*/log/*/fails/*',
+ '/channels/*/log/*/settles/*']:
register_key(key, lambda x: HTLCOwner(int(x)))
-for key in ['locked_in', 'fails', 'settles']:
- register_key(key+'/*', lambda x: HTLCOwner(int(x)))
class WalletDBUpgrader(Logger):
Why this scored 27/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.