submarine_swaps: use dict instead of defaultdict for dm_replies
What changed, and why it matters
This commit changes how Electrum's submarine swap feature tracks expected direct-message replies from Nostr relays. Previously, accessing any unknown reply ID would automatically create a placeholder 'Future' object. Now, only the client creates a Future for a specific request it sent, and only the client resolves replies, with checks that the reply ID is known. This prevents a potential issue where a malicious or buggy relay/server could create or manipulate placeholder reply entries by sending crafted messages.
Review whether any other defaultdict(asyncio.Future) patterns exist in the codebase that could allow implicit object creation from untrusted input. Consider adding regression tests for malformed reply_to handling in NostrTransport. No immediate emergency action is indicated by the diff alone.
Security signals we found
Change from defaultdict to plain dict prevents implicit object creation on arbitrary keys
Adds is_server guard so only clients process reply_to responses
Adds membership check before setting Future result, avoiding KeyError and unintended entry creation
Client explicitly creates Future for its own request before awaiting, tying lifecycle to outbound request
Evidence from the diff
In electrum/submarine_swaps.py, NostrTransport.dm_replies was a defaultdict(asyncio.Future), meaning any key access created a new Future. The patch replaces it with a plain dict. The client now explicitly creates asyncio.Future() only for its own outgoing request event_id before awaiting it. Incoming messages only set results on dm_replies when the node is not a swap server, the message has ‘reply_to’, and the reply_to key already exists in dm_replies. This removes automatic Future creation on arbitrary key access and restricts reply handling to client mode.
Changed components
electrum/submarine_swaps.pyNostrTransport classdirect message reply handling in submarine swapsInspect captured patch +7 / −4
diff --git a/electrum/submarine_swaps.py b/electrum/submarine_swaps.py
index fd3ba33..4b008d6 100644
--- a/electrum/submarine_swaps.py
+++ b/electrum/submarine_swaps.py
@@ -1480,7 +1480,7 @@ class NostrTransport(SwapServerTransport):
self.private_key = keypair.privkey
self.nostr_private_key = to_nip19('nsec', keypair.privkey.hex())
self.nostr_pubkey = keypair.pubkey.hex()[2:]
- self.dm_replies = defaultdict(asyncio.Future) # type: Dict[str, asyncio.Future[dict]]
+ self.dm_replies = {} # type: Dict[str, asyncio.Future[dict]]
self.ssl_context = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH, cafile=ca_path)
self.relay_manager = None # type: Optional[aionostr.Manager]
self.taskgroup = OldTaskGroup()
@@ -1639,7 +1639,8 @@ class NostrTransport(SwapServerTransport):
event_id = await self.send_direct_message(server_npub, json.dumps(request_data), retries=1)
if not event_id:
raise SwapServerError()
- response = await self.dm_replies[event_id]
+ self.dm_replies[event_id] = f = asyncio.Future()
+ response = await f
assert isinstance(response, dict)
if 'error' in response:
self.logger.warning(f"error from swap server [DO NOT TRUST THIS MESSAGE]: {response['error']}")
@@ -1765,8 +1766,10 @@ class NostrTransport(SwapServerTransport):
continue
content['event_id'] = event.id
content['event_pubkey'] = event.pubkey
- if 'reply_to' in content:
- self.dm_replies[content['reply_to']].set_result(content)
+ if not self.sm.is_server and 'reply_to' in content:
+ reply_to = content['reply_to']
+ if reply_to in self.dm_replies:
+ self.dm_replies[reply_to].set_result(content)
elif self.sm.is_server and 'method' in content:
try:
await self._handle_request(content)
Why this scored 40/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.