swaps: handle timeouts in send_direct_message
What changed, and why it matters
This commit improves how Electrum's submarine-swap feature handles network timeouts when sending direct messages over Nostr. Previously, a timeout while publishing a swap offer or sending an encrypted request could crash or hang the process. Now the code catches the timeout, logs a warning, optionally retries, and returns None if it still fails. Callers are updated to raise a proper SwapServerError instead of hanging. This is a robustness fix rather than a fix for a clear exploitable vulnerability.
Treat as a normal reliability/robustness improvement. Reviewers may want to verify that the recursive retry does not interact dangerously with cancellation or taskgroup shutdown, and that logging does not leak sensitive message content. No urgent security action is indicated by the diff alone.
Security signals we found
TimeoutError handling added to network publish operations
Recursive retry loop bounded by assertion (retries < 25)
send_request_to_server now raises SwapServerError on failure instead of hanging
Direct message sends moved to taskgroup.spawn to avoid blocking error paths
No cryptographic, authentication, or input-validation changes observed
Evidence from the diff
The patch modifies electrum/submarine_swaps.py in the NostrTransport class. It wraps aionostr._add_event() calls in try/except for asyncio.TimeoutError. send_direct_message() now accepts a retries parameter (capped at <25), recursively retries on TimeoutError, and returns Optional[str]. publish_offer() now logs a warning on timeout instead of propagating. send_request_to_server() passes retries=1 and raises SwapServerError() if no event_id is returned. Two other call sites now spawn the DM send into the taskgroup and/or pass retries=2. The change prevents unhandled TimeoutError exceptions and avoids indefinite awaits on direct-message replies.
Changed components
electrum/submarine_swaps.pyNostrTransport.send_direct_messageNostrTransport.publish_offerNostrTransport.send_request_to_serverInspect captured patch +33 / −19
diff --git a/electrum/submarine_swaps.py b/electrum/submarine_swaps.py
index 9a968da..adbc148 100644
--- a/electrum/submarine_swaps.py
+++ b/electrum/submarine_swaps.py
@@ -1567,25 +1567,37 @@ class NostrTransport(SwapServerTransport):
tags = [['d', f'electrum-swapserver-{self.NOSTR_EVENT_VERSION}'],
['r', 'net:' + constants.net.NET_NAME],
['expiration', str(int(time.time() + self.OFFER_UPDATE_INTERVAL_SEC + 10))]]
- event_id = await aionostr._add_event(
- self.relay_manager,
- kind=self.USER_STATUS_NIP38,
- tags=tags,
- content=json.dumps(offer),
- private_key=self.nostr_private_key)
- self.logger.info(f"published offer {event_id}")
-
- async def send_direct_message(self, pubkey: str, content: str) -> str:
+ try:
+ event_id = await aionostr._add_event(
+ self.relay_manager,
+ kind=self.USER_STATUS_NIP38,
+ tags=tags,
+ content=json.dumps(offer),
+ private_key=self.nostr_private_key)
+ self.logger.info(f"published offer {event_id}")
+ except asyncio.TimeoutError as e:
+ self.logger.warning(f"failed to publish swap offer: {str(e)}")
+
+ @ignore_exceptions
+ @log_exceptions
+ async def send_direct_message(self, pubkey: str, content: str, retries: int = 0) -> Optional[str]:
+ assert retries < 25, "Use a sane retry amount"
our_private_key = aionostr.key.PrivateKey(self.private_key)
recv_pubkey_hex = aionostr.util.from_nip19(pubkey)['object'].hex() if pubkey.startswith('npub') else pubkey
encrypted_msg = our_private_key.encrypt_message(content, recv_pubkey_hex)
- event_id = await aionostr._add_event(
- self.relay_manager,
- kind=self.EPHEMERAL_REQUEST,
- content=encrypted_msg,
- private_key=self.nostr_private_key,
- tags=[['p', recv_pubkey_hex]],
- )
+ try:
+ event_id = await aionostr._add_event(
+ self.relay_manager,
+ kind=self.EPHEMERAL_REQUEST,
+ content=encrypted_msg,
+ private_key=self.nostr_private_key,
+ tags=[['p', recv_pubkey_hex]],
+ )
+ except asyncio.TimeoutError:
+ self.logger.warning(f"sending message to {pubkey} failed: timeout. {retries=}")
+ if retries > 0:
+ return await self.send_direct_message(pubkey, content, retries - 1)
+ return None
return event_id
@log_exceptions
@@ -1593,7 +1605,9 @@ class NostrTransport(SwapServerTransport):
self.logger.debug(f"swapserver req: method: {method} relays: {self.relays}")
request_data['method'] = method
server_npub = self.config.SWAPSERVER_NPUB
- event_id = await self.send_direct_message(server_npub, json.dumps(request_data))
+ 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]
if 'error' in response:
self.logger.warning(f"error from swap server [DO NOT TRUST THIS MESSAGE]: {response['error']}")
@@ -1725,7 +1739,7 @@ class NostrTransport(SwapServerTransport):
"error": str(e)[:100],
"reply_to": event.id,
})
- await self.send_direct_message(event.pubkey, error_response)
+ await self.taskgroup.spawn(self.send_direct_message(event.pubkey, error_response))
else:
self.logger.info(f'unknown message {content}')
@@ -1747,7 +1761,7 @@ class NostrTransport(SwapServerTransport):
raise Exception(method)
r['reply_to'] = event_id
self.logger.debug(f'sending response id={event_id}')
- await self.send_direct_message(event_pubkey, json.dumps(r))
+ await self.taskgroup.spawn(self.send_direct_message(event_pubkey, json.dumps(r), retries=2))
def _store_last_swapserver_relays(self, relays: Sequence[str]):
self._last_swapserver_relays = relays
Why this scored 32/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.