From 3693c38e37f2760b95805fa10cc202149b7479af Mon Sep 17 00:00:00 2001 From: f321x Date: Mon, 19 May 2025 11:19:06 +0200 Subject: [PATCH] swaps: replace offers dict with class, fix incorrect naming introduces a class SwapOffer which is used instead of passing around offers in dicts. Also fixes incorrect variable naming of swapserver npubs / public keys by assigning the npub instead of the hex pubkey to config.SWAPSERVER_NPUB --- electrum/gui/qml/qeswaphelper.py | 21 +++++---- electrum/gui/qt/main_window.py | 5 +- electrum/gui/qt/swap_dialog.py | 32 +++++++------ electrum/submarine_swaps.py | 80 +++++++++++++++++++------------- 4 files changed, 81 insertions(+), 57 deletions(-) diff --git a/electrum/gui/qml/qeswaphelper.py b/electrum/gui/qml/qeswaphelper.py index ce721850d..b694b7962 100644 --- a/electrum/gui/qml/qeswaphelper.py +++ b/electrum/gui/qml/qeswaphelper.py @@ -2,7 +2,7 @@ import asyncio import concurrent import threading from enum import IntEnum -from typing import Union, Optional +from typing import Union, Optional, TYPE_CHECKING, Sequence from PyQt6.QtCore import (pyqtProperty, pyqtSignal, pyqtSlot, QObject, QTimer, pyqtEnum, QAbstractListModel, Qt, QModelIndex) @@ -22,6 +22,9 @@ from .qetypes import QEAmount from .qewallet import QEWallet from .util import QtEventListener, qt_event_listener +if TYPE_CHECKING: + from electrum.submarine_swaps import SwapOffer + class InvalidSwapParameters(Exception): pass @@ -64,16 +67,16 @@ class QESwapServerNPubListModel(QAbstractListModel): self._services = [] self.endResetModel() - def initModel(self, items): + def initModel(self, items: Sequence['SwapOffer']): self.beginInsertRows(QModelIndex(), len(items), len(items)) self._services = [{ - 'npub': x['pubkey'], - 'percentage_fee': x['percentage_fee'], - 'mining_fee': x['mining_fee'], - 'min_amount': x['min_amount'], - 'max_forward_amount': x['max_forward_amount'], - 'max_reverse_amount': x['max_reverse_amount'], - 'timestamp': age(x['timestamp']), + 'npub': x.server_npub, + 'percentage_fee': x.pairs.percentage, + 'mining_fee': x.pairs.mining_fee, + 'min_amount': x.pairs.min_amount, + 'max_forward_amount': x.pairs.max_forward, + 'max_reverse_amount': x.pairs.max_reverse, + 'timestamp': age(x.timestamp), } for x in items] self.endInsertRows() self.countChanged.emit() diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index 9dfd24327..d5cc9a473 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -106,6 +106,7 @@ from electrum.gui.common_qt.util import TaskThread if TYPE_CHECKING: from . import ElectrumGui + from electrum.submarine_swaps import SwapOffer class StatusBarButton(QToolButton): @@ -1333,8 +1334,8 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): if choice is None: return False self.config.SWAPSERVER_NPUB = choice - pairs = transport.get_offer(choice) - sm.update_pairs(pairs) + offer = transport.get_offer(choice) + sm.update_pairs(offer.pairs) return True @qt_event_listener diff --git a/electrum/gui/qt/swap_dialog.py b/electrum/gui/qt/swap_dialog.py index 651eac9a2..0992a13ab 100644 --- a/electrum/gui/qt/swap_dialog.py +++ b/electrum/gui/qt/swap_dialog.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Optional, Union, Tuple +from typing import TYPE_CHECKING, Optional, Union, Tuple, Sequence from PyQt6.QtCore import pyqtSignal, Qt from PyQt6.QtWidgets import QLabel, QVBoxLayout, QGridLayout, QPushButton @@ -21,7 +21,7 @@ from .my_treeview import create_toolbar_with_menu if TYPE_CHECKING: from .main_window import ElectrumWindow - from electrum.submarine_swaps import SwapServerTransport + from electrum.submarine_swaps import SwapServerTransport, SwapOffer CANNOT_RECEIVE_WARNING = _( """The requested amount is higher than what you can receive in your currently open channels. @@ -31,7 +31,7 @@ Do you want to continue?""" ) -ROLE_PUBKEY = Qt.ItemDataRole.UserRole + 1000 +ROLE_NPUB = Qt.ItemDataRole.UserRole + 1000 class InvalidSwapParameters(Exception): pass @@ -51,7 +51,7 @@ class SwapDialog(WindowModalDialog, QtEventListener): toolbar, menu = create_toolbar_with_menu(self.config, '') menu.addAction( _('Choose swap provider'), - lambda: self.window.choose_swapserver_dialog(transport), + lambda: self.choose_swap_server(transport), ).setEnabled(not self.config.SWAPSERVER_URL) vbox.addLayout(toolbar) self.description_label = WWLabel(self.get_description()) @@ -395,6 +395,10 @@ class SwapDialog(WindowModalDialog, QtEventListener): capacityType="receiving" if self.is_reverse else "sending", ) + def choose_swap_server(self, transport: 'SwapServerTransport') -> None: + self.window.choose_swapserver_dialog(transport) # type: ignore + self.update() + class SwapServerDialog(WindowModalDialog, QtEventListener): @@ -425,21 +429,21 @@ class SwapServerDialog(WindowModalDialog, QtEventListener): def run(self): if self.exec() != 1: - return + return None if item := self.servers_list.currentItem(): - return item.data(0, ROLE_PUBKEY) + return item.data(0, ROLE_NPUB) + return None - def update_servers_list(self, servers): + def update_servers_list(self, servers: Sequence['SwapOffer']): self.servers_list.clear() from electrum.util import age items = [] for x in servers: - # fixme: these fields have not been sanitized yet - last_seen = age(x['timestamp']) - fee = f"{x['percentage_fee']}% + {x['mining_fee']} sats" - max_forward = self.window.format_amount(x['max_forward_amount']) + ' ' + self.window.base_unit() - max_reverse = self.window.format_amount(x['max_reverse_amount']) + ' ' + self.window.base_unit() - item = QTreeWidgetItem([x['pubkey'][0:10], fee, max_forward, max_reverse, last_seen]) - item.setData(0, ROLE_PUBKEY, x['pubkey']) + last_seen = age(x.timestamp) + fee = f"{x.pairs.percentage}% + {x.pairs.mining_fee} sats" + max_forward = self.window.format_amount(x.pairs.max_forward) + ' ' + self.window.base_unit() + max_reverse = self.window.format_amount(x.pairs.max_reverse) + ' ' + self.window.base_unit() + item = QTreeWidgetItem([x.server_pubkey[0:10], fee, max_forward, max_reverse, last_seen]) + item.setData(0, ROLE_NPUB, x.server_npub) items.append(item) self.servers_list.insertTopLevelItems(0, items) diff --git a/electrum/submarine_swaps.py b/electrum/submarine_swaps.py index 054915a99..e130b83f9 100644 --- a/electrum/submarine_swaps.py +++ b/electrum/submarine_swaps.py @@ -135,7 +135,7 @@ class SwapServerError(Exception): def now(): return int(time.time()) -@attr.s +@attr.s(frozen=True) class SwapFees: percentage = attr.ib(type=int) mining_fee = attr.ib(type=int) @@ -143,6 +143,18 @@ class SwapFees: max_forward = attr.ib(type=int) max_reverse = attr.ib(type=int) +@attr.frozen +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) + + @property + def server_npub(self): + return to_nip19('npub', self.server_pubkey) + @stored_in('submarine_swaps') @attr.s class SwapData(StoredObject): @@ -962,7 +974,7 @@ class SwapManager(Logger): zeroed_num_str = f"{num_str[:digits]}{(len(num_str[digits:])) * '0'}" return int(zeroed_num_str) - def update_pairs(self, pairs): + def update_pairs(self, pairs: SwapFees): self.logger.info(f'updating fees {pairs}') self.mining_fee = pairs.mining_fee self.percentage = pairs.percentage @@ -1365,7 +1377,7 @@ class NostrTransport(SwapServerTransport): def __init__(self, config, sm, keypair): SwapServerTransport.__init__(self, config=config, sm=sm) - self._offers = {} # type: Dict[str, Dict] + self._offers = {} # type: Dict[str, SwapOffer] self.private_key = keypair.privkey self.nostr_private_key = to_nip19('nsec', keypair.privkey.hex()) self.nostr_pubkey = keypair.pubkey.hex()[2:] @@ -1447,29 +1459,19 @@ class NostrTransport(SwapServerTransport): ) return self.relay_manager - def get_offer(self, pubkey): - offer = self._offers.get(pubkey) - return self._parse_offer(offer) + def get_offer(self, pubkey) -> Optional[SwapOffer]: + return self._offers.get(pubkey) - def get_recent_offers(self) -> Sequence[Dict]: + def get_recent_offers(self) -> Sequence[SwapOffer]: # filter to fresh timestamps now = int(time.time()) - recent_offers = [x for x in self._offers.values() if now - x['timestamp'] < 3600] + recent_offers = [x for x in self._offers.values() if now - x.timestamp < 3600] # sort by proof-of-work - recent_offers = sorted(recent_offers, key=lambda x: x['pow_bits'], reverse=True) + recent_offers = sorted(recent_offers, key=lambda x: x.pow_bits, reverse=True) # cap list size recent_offers = recent_offers[:20] return recent_offers - def _parse_offer(self, offer): - return SwapFees( - percentage=offer['percentage_fee'], - mining_fee=offer['mining_fee'], - min_amount=offer['min_amount'], - max_forward=offer['max_forward_amount'], - max_reverse=offer['max_reverse_amount'], - ) - @ignore_exceptions @log_exceptions async def publish_offer(self, sm) -> None: @@ -1515,8 +1517,8 @@ class NostrTransport(SwapServerTransport): async def send_request_to_server(self, method: str, request_data: dict) -> dict: self.logger.debug(f"swapserver req: method: {method} relays: {self.relays}") request_data['method'] = method - server_pubkey = self.config.SWAPSERVER_NPUB - event_id = await self.send_direct_message(server_pubkey, json.dumps(request_data)) + server_npub = self.config.SWAPSERVER_NPUB + event_id = await self.send_direct_message(server_npub, json.dumps(request_data)) 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']}") @@ -1544,12 +1546,13 @@ class NostrTransport(SwapServerTransport): continue if tags.get('r') != f"net:{constants.net.NET_NAME}": continue + if (event.created_at > time.time() + 60 * 60 + or event.created_at < time.time() - 60 * 60): + continue # check if this is the most recent event for this pubkey pubkey = event.pubkey - ts = self._offers.get(pubkey, {}).get('timestamp', 0) - if (event.created_at <= ts - or event.created_at > time.time() + 60 * 60 - or event.created_at < time.time() - 60 * 60): + prev_offer = self._offers.get(to_nip19('npub', pubkey)) + if prev_offer and event.created_at <= prev_offer.timestamp: continue try: pow_bits = get_nostr_ann_pow_amount( @@ -1561,15 +1564,28 @@ class NostrTransport(SwapServerTransport): if pow_bits < self.config.SWAPSERVER_POW_TARGET: self.logger.debug(f"too low pow: {pubkey}: pow: {pow_bits} nonce: {content.get('pow_nonce', 0)}") continue - content['pow_bits'] = pow_bits - content['pubkey'] = pubkey - content['timestamp'] = event.created_at server_relays = content['relays'].split(',') if 'relays' in content else [] - content['relays'] = server_relays[:10] # limit to 10 relays - self._offers[pubkey] = content - if self.config.SWAPSERVER_NPUB == pubkey: - pairs = self._parse_offer(content) + try: + pairs = SwapFees( + percentage=content['percentage_fee'], + mining_fee=content['mining_fee'], + min_amount=content['min_amount'], + max_forward=content['max_forward_amount'], + max_reverse=content['max_reverse_amount'], + ) + except Exception: + self.logger.debug(f"swap fees couldn't be parsed", exc_info=True) + continue + offer = SwapOffer( + pairs=pairs, + relays=server_relays[:10], + timestamp=event.created_at, + server_pubkey=pubkey, + pow_bits=pow_bits, + ) + if self.config.SWAPSERVER_NPUB == offer.server_npub: self.sm.update_pairs(pairs) + self._offers[offer.server_npub] = offer # mirror event to other relays await self.taskgroup.spawn(self.rebroadcast_event(event, server_relays)) @@ -1581,7 +1597,7 @@ class NostrTransport(SwapServerTransport): while True: previous_relays = self._last_swapserver_relays await self.sm.pairs_updated.wait() - latest_known_relays = self._offers[self.config.SWAPSERVER_NPUB]['relays'] + latest_known_relays = self._offers[self.config.SWAPSERVER_NPUB].relays if latest_known_relays != previous_relays: self.logger.debug(f"swapserver relays changed, updating relay list.") # store the latest known relays to a file