diff --git a/electrum/gui/common_qt/util.py b/electrum/gui/common_qt/util.py index 11d06a913..13f8e2e15 100644 --- a/electrum/gui/common_qt/util.py +++ b/electrum/gui/common_qt/util.py @@ -3,7 +3,7 @@ import os.path from PyQt6 import QtGui from PyQt6.QtCore import Qt -from PyQt6.QtGui import QColor, QPen, QPaintDevice, QFontDatabase +from PyQt6.QtGui import QColor, QPen, QPaintDevice, QFontDatabase, QImage import qrcode from electrum.i18n import _ @@ -87,3 +87,32 @@ def draw_qr( boxsize - 1, boxsize - 1) qp.end() + +def paintQR(data) -> Optional[QImage]: + if not data: + return None + + # Create QR code + qr = qrcode.QRCode() + qr.add_data(data) + + # Create a QImage to draw on + matrix = qr.get_matrix() + k = len(matrix) + boxsize = 5 + size = k * boxsize + + # Create the image with appropriate size + base_img = QImage(size, size, QImage.Format.Format_ARGB32) + + # Use draw_qr to paint on the image + draw_qr( + qr=qr, + paint_device=base_img, + is_enabled=True, + min_boxsize=boxsize + ) + + return base_img + + diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py index 1ac0f08ba..ccb5d89db 100644 --- a/electrum/lnpeer.py +++ b/electrum/lnpeer.py @@ -550,7 +550,8 @@ class Peer(Logger, EventListener): public_channels = [chan for chan in self.lnworker.channels.values() if chan.is_public()] if public_channels: alias = self.lnworker.config.LIGHTNING_NODE_ALIAS - self.send_node_announcement(alias) + color = self.lnworker.config.LIGHTNING_NODE_COLOR_RGB + self.send_node_announcement(alias, color) for chan in public_channels: if chan.is_open() and chan.peer_state == PeerState.GOOD: self.maybe_send_channel_announcement(chan) @@ -1725,13 +1726,13 @@ class Peer(Logger, EventListener): self.lnworker.save_channel(chan) self.maybe_mark_open(chan) - def send_node_announcement(self, alias:str): + def send_node_announcement(self, alias:str, color_hex:str): from .channel_db import NodeInfo timestamp = int(time.time()) node_id = privkey_to_pubkey(self.privkey) features = self.features.for_node_announcement() flen = features.min_len() - rgb_color = bytes.fromhex('000000') + rgb_color = bytes.fromhex(color_hex) alias = bytes(alias, 'utf8') alias += bytes(32 - len(alias)) if self.lnworker.config.LIGHTNING_LISTEN is not None: diff --git a/electrum/lnworker.py b/electrum/lnworker.py index 958224274..067b52110 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -1093,6 +1093,7 @@ class LNWallet(LNWorker): group_id = group_id, timestamp = timestamp or 0, label=label, + direction=direction, ) out[payment_hash.hex()] = item for chan in itertools.chain(self.channels.values(), self.channel_backups.values()): # type: AbstractChannel @@ -1112,6 +1113,7 @@ class LNWallet(LNWorker): fee_msat = None, payment_hash = None, preimage = None, + direction=None, ) out[funding_txid] = item item = chan.get_closing_height() @@ -1130,6 +1132,7 @@ class LNWallet(LNWorker): fee_msat = None, payment_hash = None, preimage = None, + direction=None, ) out[closing_txid] = item diff --git a/electrum/plugins/nwc/__init__.py b/electrum/plugins/nwc/__init__.py new file mode 100644 index 000000000..b057ab98b --- /dev/null +++ b/electrum/plugins/nwc/__init__.py @@ -0,0 +1,53 @@ +from electrum.commands import plugin_command +from typing import TYPE_CHECKING +from electrum.simple_config import SimpleConfig, ConfigVar + +if TYPE_CHECKING: + from .nwcserver import NWCServerPlugin + from electrum.commands import Commands + +plugin_name = "nwc" + +# Most NWC clients only use the first relay encoded in the connection string. +# This relay will be used as the first relay in the connection string. +SimpleConfig.NWC_RELAY = ConfigVar( + key='nwc_relay', + default='wss://relay.getalby.com/v1', + type_=str, + plugin=plugin_name +) + + +@plugin_command('', plugin_name) +async def add_connection( + self: 'Commands', + name: str, + daily_limit_sat=None, + valid_for_sec=None, + plugin: 'NWCServerPlugin' = None) -> str: + """ + Create a new NWC connection string. + + arg:str:name:name for the connection (e.g. nostr client name) + arg:int:daily_limit_sat:optional daily spending limit in satoshis + arg:int:valid_for_sec:optional lifetime of the connection string in seconds + """ + connection_string: str = plugin.create_connection(name, daily_limit_sat, valid_for_sec) + return connection_string + +@plugin_command('', plugin_name) +async def remove_connection(self: 'Commands', name: str, plugin=None) -> str: + """ + Remove a connection by name. + arg:str:name:connection name, use list_connections to see all connections + """ + plugin.remove_connection(name) + return f"removed connection {name}" + +@plugin_command('', plugin_name) +async def list_connections(self: 'Commands', plugin=None) -> dict: + """ + List all connections by name. + """ + connections: dict = plugin.list_connections() + return connections diff --git a/electrum/plugins/nwc/cmdline.py b/electrum/plugins/nwc/cmdline.py new file mode 100644 index 000000000..3b321f9ac --- /dev/null +++ b/electrum/plugins/nwc/cmdline.py @@ -0,0 +1,16 @@ +from .nwcserver import NWCServerPlugin +from electrum.plugin import hook + +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from electrum.daemon import Daemon + from electrum.wallet import Abstract_Wallet + +class Plugin(NWCServerPlugin): + + def __init__(self, *args): + NWCServerPlugin.__init__(self, *args) + + @hook + def daemon_wallet_loaded(self, daemon: 'Daemon', wallet: 'Abstract_Wallet'): + self.start_plugin(wallet) diff --git a/electrum/plugins/nwc/manifest.json b/electrum/plugins/nwc/manifest.json new file mode 100644 index 000000000..e31ba9a2e --- /dev/null +++ b/electrum/plugins/nwc/manifest.json @@ -0,0 +1,8 @@ +{ + "fullname": "Nostr Wallet Connect", + "name": "nwc", + "description": "This plugin allows remote control of Electrum lightning wallets via Nostr NIP-47.", + "author": "The Electrum Developers", + "available_for": ["cmdline", "qt"], + "version": "0.0.1" +} diff --git a/electrum/plugins/nwc/nwcserver.py b/electrum/plugins/nwc/nwcserver.py new file mode 100644 index 000000000..68b4b2cca --- /dev/null +++ b/electrum/plugins/nwc/nwcserver.py @@ -0,0 +1,913 @@ +from electrum.lnworker import PaymentDirection +from electrum.plugin import BasePlugin, hook +from electrum.logging import Logger +from electrum.util import log_exceptions, ca_path, OldTaskGroup, get_asyncio_loop, InvoiceError, \ + LightningHistoryItem, event_listener, EventListener, make_aiohttp_proxy_connector, \ + get_running_loop +from electrum.invoices import Invoice, Request, PR_UNKNOWN, PR_PAID, BaseInvoice, PR_INFLIGHT +from electrum.constants import net +import electrum_aionostr as aionostr +from electrum_aionostr.event import Event as nEvent +from electrum_aionostr.key import PrivateKey + +import asyncio +import json +import time +import ssl +import logging +import urllib.parse + +from typing import TYPE_CHECKING, Optional, List, Tuple, Awaitable + +if TYPE_CHECKING: + from electrum.simple_config import SimpleConfig + from electrum.wallet import Abstract_Wallet + from aiohttp_socks import ProxyConnector + + +STORAGE_NAME = 'nwc_plugin' + +class NWCServerPlugin(BasePlugin): + URI_SCHEME = 'nostr+walletconnect://' + + def __init__(self, parent, config: 'SimpleConfig', name): + BasePlugin.__init__(self, parent, config, name) + self.config = config + self.connections = None # type: Optional[dict[str, dict]] # pubkey_hex -> connection data + self.nwc_server = None # type: Optional[NWCServer] + self.taskgroup = OldTaskGroup() + self.initialized = False + if not self.config.NWC_RELAY: # type: ignore # defined in __init__ + self.config.NWC_RELAY = self.config.NOSTR_RELAYS.split(',')[0] + self.logger.debug(f"NWCServerPlugin created, waiting for wallet to load...") + + def start_plugin(self, wallet: 'Abstract_Wallet'): + storage = self.get_plugin_storage(wallet) + self.connections = storage['connections'] + self.delete_expired_connections() + self.nwc_server = NWCServer(self.config, wallet, self.taskgroup) + asyncio.run_coroutine_threadsafe(self.taskgroup.spawn(self.nwc_server.run()), get_asyncio_loop()) + self.initialized = True + + @hook + def close_wallet(self, *args, **kwargs): + async def close(): + if self.nwc_server and self.nwc_server.manager: + self.nwc_server.do_stop = True + await self.nwc_server.manager.close() + await self.taskgroup.cancel_remaining() + asyncio.run_coroutine_threadsafe( + close(), + get_asyncio_loop() + ) + self.logger.debug(f"NWCServerPlugin closed, stopping taskgroup") + + @staticmethod + def get_plugin_storage(wallet: 'Abstract_Wallet') -> dict: + storage = wallet.db.get_dict(STORAGE_NAME) + if 'connections' not in storage: + storage['connections'] = {} + return storage + + def delete_expired_connections(self): + if self.connections is None: + return + now = int(time.time()) + connections = list(self.connections.items()) + for pubkey, conn in connections: + if 'valid_until' in conn and conn['valid_until'] <= now: + del self.connections[pubkey] + self.logger.info(f"Deleting expired NWC connection: {pubkey}") + if len(self.connections) != len(connections) and self.nwc_server: + self.nwc_server.restart_event_handler() + + def create_connection(self, name: str, daily_limit_sat: Optional[int], valid_for_sec: Optional[int]) -> str: + assert self.connections is not None, f"Wallet not loaded yet" + assert len(name) > 0, f"Invalid or missing connection name: {name}" + + for conn in self.connections.values(): + if conn['name'] == name: + raise ValueError(f"Connection name already exists: {name}") + + our_connection_secret = PrivateKey() + our_connection_pubkey: str = our_connection_secret.public_key.hex() + + client_secret = PrivateKey() + client_pubkey: str = client_secret.public_key.hex() + + connection = { + "name": name, + "our_secret": our_connection_secret.hex(), + "budget_spends": [] + } + if daily_limit_sat: + connection['daily_limit_sat'] = daily_limit_sat + if valid_for_sec: + connection['valid_until'] = int(time.time()) + valid_for_sec + connection_string = self.serialize_connection_uri(client_secret.hex(), our_connection_pubkey) + self.connections[client_pubkey] = connection + self.logger.debug(f"Added nwc connection: {name=}, {valid_for_sec=}, {daily_limit_sat=}") + + if self.nwc_server: + self.nwc_server.restart_event_handler() + + return connection_string + + def remove_connection(self, name: str) -> None: + assert self.connections is not None, f"Wallet not loaded yet" + for pubkey, conn in self.connections.items(): + if conn['name'] == name: + del self.connections[pubkey] + return + raise ValueError(f"Connection name not found: {name}") + + def list_connections(self) -> dict: + assert self.connections is not None, f"Wallet not loaded yet" + self.delete_expired_connections() + connections_without_secrets = {} + for client_pub, conn in self.connections.items(): + data = { + 'valid_until': conn.get('valid_until', "unset"), + 'daily_limit_sat': conn.get('daily_limit_sat', "unset"), + 'client_pub': client_pub, + } + connections_without_secrets[conn['name']] = data + return connections_without_secrets + + def serialize_connection_uri(self, client_secret_hex: str, our_pubkey_hex: str) -> str: + base_uri = f"{self.URI_SCHEME}{our_pubkey_hex}" + + # the NWC_RELAY is added first as this is the first relay parsed by clients + query_params = [f"relay={urllib.parse.quote(self.config.NWC_RELAY)}"] # type: ignore + for relay in self.config.NOSTR_RELAYS.split(",")[:5]: + if relay != self.config.NWC_RELAY: # type: ignore + query_params.append(f"relay={urllib.parse.quote(relay)}") + + query_params.append(f"secret={client_secret_hex}") + + # Construct the final URI + query_string = "&".join(query_params) + uri = f"{base_uri}?{query_string}" + + return uri + + +class NWCServer(Logger, EventListener): + INFO_EVENT_KIND: int = 13194 + REQUEST_EVENT_KIND: int = 23194 + RESPONSE_EVENT_KIND: int = 23195 + NOTIFICATION_EVENT_KIND: int = 23196 + SUPPORTED_METHODS: list[str] = ['pay_invoice', 'multi_pay_invoice', 'make_invoice', + 'lookup_invoice', 'get_balance', 'get_info', 'list_transactions', + 'notifications'] + SUPPORTED_NOTIFICATIONS: list[str] = ["payment_sent", "payment_received"] + + def __init__(self, config: 'SimpleConfig', wallet: 'Abstract_Wallet', taskgroup: 'OldTaskGroup'): + Logger.__init__(self) + self.config = config # type: 'SimpleConfig' + self.wallet = wallet # type: 'Abstract_Wallet' + storage = wallet.db.get_dict(STORAGE_NAME) # type: dict + self.connections = storage['connections'] # type: dict[str, dict] # client hex pubkey -> connection data + self.relays = config.NOSTR_RELAYS.split(",") or [] # type: List[str] + self.do_stop = False + self.taskgroup = taskgroup # type: 'OldTaskGroup' + self.ssl_context = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH, cafile=ca_path) + self.manager = None # type: Optional[aionostr.Manager] + # the task is stored so it can be cancelled when the connections change + self.event_handler_task = None # type: Optional[asyncio.Task] + self.register_callbacks() + + def get_relay_manager(self) -> aionostr.Manager: + assert get_asyncio_loop() == get_running_loop(), "NWCServer must run in the aio event loop" + nostr_logger = self.logger.getChild('aionostr') + nostr_logger.setLevel(logging.INFO) + network = self.wallet.lnworker.network + if network.proxy and network.proxy.enabled: + proxy = make_aiohttp_proxy_connector(network.proxy, self.ssl_context) + else: + proxy: Optional['ProxyConnector'] = None + return aionostr.Manager( + # ensure that we also connect to NWC_RELAY, even if it's not in the NOSTR_RELAYS + relays=set(self.config.NOSTR_RELAYS.split(",")) | {self.config.NWC_RELAY}, # type: ignore + private_key=PrivateKey().hex(), # use random private key + log=nostr_logger, + ssl_context=self.ssl_context, + proxy=proxy + ) + + @log_exceptions + async def run(self) -> None: + while True: + # wait until connections have been set up and network is available + while (not self.connections + or not self.relays + or not self.wallet.network + or not self.wallet.network.is_connected() + or not self.wallet.lnworker): + await asyncio.sleep(5) + + if not await self.refresh_manager(): + await asyncio.sleep(30) + continue + + try: + await self.publish_info_event() + self.event_handler_task = await self.taskgroup.spawn(self.handle_requests()) + await self.event_handler_task + except asyncio.CancelledError: + if self.do_stop: + return + self.logger.debug("Restarting nwc event handler") + except Exception as e: + self.logger.exception(f"Restarting nwc event handler after exception: {e}") + if self.manager: # close the manager so refresh_manager() will recreate it + await self.manager.close() + self.manager = None + await asyncio.sleep(60) + + async def refresh_manager(self) -> bool: + """Checks if manager is still connected to relays, if not recreates it and reconnects""" + if self.manager is None: + # on startup and proxy change + self.manager = self.get_relay_manager() + + if len(self.manager.relays) <= 0 < len(self.relays): + # manager lost all connections (relays) + # setup new manager so relays are populated again + await self.manager.close() + self.manager = self.get_relay_manager() + + if not self.manager.connected: + # not set in new manager instances + await self.manager.connect() + + if len(self.manager.relays) <= 0: + # manager should still have relays after connecting + self.logger.warning(f"Could not connect to any relays!") + return False + + return True + + def restart_event_handler(self) -> None: + """To be called when the connections change so we restart with a new filter""" + if self.event_handler_task: + self.event_handler_task.cancel() + + @event_listener + def on_event_proxy_set(self, *args): + async def restart_manager(): + if self.manager: + await self.manager.close() + self.manager = None + await asyncio.sleep(5) + self.restart_event_handler() + self.logger.info("proxy changed, restarting nwc plugin nostr transport") + asyncio.run_coroutine_threadsafe(restart_manager(), get_asyncio_loop()) + + async def handle_requests(self) -> None: + query = { + "authors": list(self.connections.keys()), # the pubkeys of the client connections + "kinds": [self.REQUEST_EVENT_KIND], + "limit": 0, + "since": int(time.time()) + } + async for event in self.manager.get_events(query, single_event=False, only_stored=False): + if event.pubkey not in self.connections.keys(): + continue + + # check if the connection is expired, if so we delete it and send an error + valid_until: Optional[int] = self.connections[event.pubkey].get('valid_until') + if valid_until and valid_until <= int(time.time()): + await self.send_error(event, "UNAUTHORIZED", "Connection expired") + del self.connections[event.pubkey] + self.logger.info(f"Deleting expired NWC connection: {event.pubkey}") + self.restart_event_handler() + continue + + if event.kind != self.REQUEST_EVENT_KIND: + self.logger.debug(f"Unknown nwc request event kind: {event.kind}") + await self.send_error(event, "NOT_IMPLEMENTED") + continue + + if event.created_at < int(time.time()) - 15: + self.logger.debug(f"old nwc request event: {event.content}") + continue + + # decrypt the requests content + our_secret: str = self.connections[event.pubkey]['our_secret'] + our_connection_secret = PrivateKey(raw_secret=bytes.fromhex(our_secret)) + try: + content = our_connection_secret.decrypt_message(event.content, event.pubkey) + content = json.loads(content) + event.content = content + params: dict = content['params'] + except Exception: + self.logger.debug(f"Invalid request event content: {event.content}", exc_info=True) + continue + + # run the according method + method: str = content.get('method') + self.logger.debug(f"got request: {method=}, {params=}") + task: Optional[Awaitable] = None + if method == "pay_invoice": + task = self.handle_pay_invoice(event, params) + elif method == "multi_pay_invoice": + task = self.handle_multi_pay_invoice(event, params) + elif method == "make_invoice": + task = self.handle_make_invoice(event, params) + elif method == "lookup_invoice": + task = self.handle_lookup_invoice(event, params) + elif method == "get_balance": + task = self.handle_get_balance(event) + elif method == "get_info": + task = self.handle_get_info(event) + elif method == "list_transactions": + task = self.handle_list_transactions(event, params) + else: + self.logger.debug(f"Unsupported nwc method requested: {content.get('method')}") + await self.send_error(event, "NOT_IMPLEMENTED", f"{method} not supported") + continue + + if task: + await self.taskgroup.spawn(self.run_request_task(task, event)) + + async def run_request_task(self, task: Awaitable, request_event: nEvent) -> None: + """Catches request handling exceptions and send an error response""" + try: + await task + except Exception as e: + self.logger.exception("Error handling nwc request") + await self.send_error(request_event, "INTERNAL", f"Error handling request: {str(e)[:100]}") + + async def send_error(self, causing_event: nEvent, error_type: str, error_msg: str = "") -> None: + """Sends an error as response to the passed nEvent, containing the error type and message""" + to_pubkey_hex = causing_event.pubkey + response_to_id = causing_event.id + res_type = None + if isinstance(causing_event.content, dict): # we have replaced the content with the decrypted content + if 'method' in causing_event.content: + res_type = causing_event.content['method'] + content = self.get_error_response(error_type, error_msg, res_type) + await self.send_encrypted_response(to_pubkey_hex, json.dumps(content), response_to_id) + + @staticmethod + def get_error_response(error_type: str, error_msg: str = "", method: Optional[str] = None) -> dict: + content = { + "error": { + "code": error_type, + "message": error_msg + } + } + if method: + content['result_type'] = method + return content + + async def send_encrypted_response( + self, + to_pubkey_hex: str, + content: str, + response_event_id: str, + *, + add_tags: Optional[List] = None + ) -> None: + """Encrypts content for the given pubkey and sends it as response to the given event id""" + our_secret: str = self.connections[to_pubkey_hex]['our_secret'] + tags = [['p', to_pubkey_hex], ['e', response_event_id]] + if add_tags: + tags.extend(add_tags) + + await self.taskgroup.spawn(aionostr._add_event( + self.manager, + kind=self.RESPONSE_EVENT_KIND, + tags=tags, + content=self.encrypt_to_pubkey(content, to_pubkey_hex), + # use the private key we generated for this specific client + private_key=our_secret + ) + ) + + @log_exceptions + async def handle_pay_invoice(self, request_event: nEvent, params: dict) -> None: + """ + Handler for pay_invoice method + https://github.com/nostr-protocol/nips/blob/75f246ed987c23c99d77bfa6aeeb1afb669e23f7/47.md#pay_invoice + """ + invoice: str = params.get('invoice', "") + amount_msat: Optional[int] = params.get('amount') + response = await self.pay_invoice(invoice, amount_msat, request_event.pubkey) + response['result_type'] = 'pay_invoice' + await self.send_encrypted_response(request_event.pubkey, json.dumps(response), request_event.id) + + @log_exceptions + async def handle_multi_pay_invoice(self, request_event: nEvent, params: dict) -> None: + """ + Handler for multi_pay_invoice method. + https://github.com/nostr-protocol/nips/blob/75f246ed987c23c99d77bfa6aeeb1afb669e23f7/47.md#multi_pay_invoice + """ + invoices: List[dict] = params.get('invoices', []) + for invoice_req in invoices: + invoice: str = invoice_req.get('invoice', "") + amount_msat: Optional[int] = invoice_req.get('amount') + inv_id: Optional[str] = invoice_req.get('id') + response = await self.pay_invoice(invoice, amount_msat, request_event.pubkey) + if not inv_id: + # if we have no id we need the payment hash + try: + inv_id = Invoice.from_bech32(invoice).rhash + except InvoiceError: + inv_id = "none" + response['result_type'] = 'multi_pay_invoice' + id_tag = [['d', inv_id]] + await self.send_encrypted_response( + request_event.pubkey, + json.dumps(response), + request_event.id, + add_tags=id_tag + ) + + @log_exceptions + async def handle_make_invoice(self, request_event: nEvent, params: dict): + """ + Handler for make_invoice method. + https://github.com/nostr-protocol/nips/blob/75f246ed987c23c99d77bfa6aeeb1afb669e23f7/47.md#make_invoice + """ + amount_msat = params.get('amount', 0) # type: Optional[int] + description = params.get('description', params.get('description_hash', "")) # type: str + expiry = params.get('expiry', 3600) # type: int + # create payment request + key: str = self.wallet.create_request( + amount_sat=amount_msat // 1000, + message=description, + exp_delay=expiry, + address=None + ) + req: Request = self.wallet.get_request(key) + try: + lnaddr, b11 = self.wallet.lnworker.get_bolt11_invoice( + payment_hash=req.payment_hash, + amount_msat=amount_msat, + message=description, + expiry=expiry, + fallback_address=None + ) + except Exception: + self.logger.exception(f"failed to create invoice") + response = self.get_error_response("INTERNAL", "Failed to create invoice", "make_invoice") + return await self.send_encrypted_response(request_event.pubkey, json.dumps(response), request_event.id) + response = { + "result_type": "make_invoice", + "result": { + "type": "incoming", + "invoice": b11, + "description": description, + "payment_hash": lnaddr.paymenthash.hex(), + "amount": amount_msat, + "created_at": lnaddr.date, + "expires_at": req.get_expiration_date(), + "metadata": {}, + "fees_paid": 0 # the spec wants this?? + } + } + self.logger.debug(f"make_invoice response: {response}") + await self.send_encrypted_response(request_event.pubkey, json.dumps(response), request_event.id) + + @log_exceptions + async def handle_lookup_invoice(self, request_event: nEvent, params: dict): + """ + https://github.com/nostr-protocol/nips/blob/75f246ed987c23c99d77bfa6aeeb1afb669e23f7/47.md#lookup_invoice + """ + invoice = params.get('invoice') + payment_hash = params.get('payment_hash') + if invoice: + invoice = Invoice.from_bech32(invoice) + elif payment_hash: + invoice = self.wallet.get_invoice(payment_hash) or self.wallet.get_request(payment_hash) + else: + response = self.get_error_response("NOT_FOUND", "Missing invoice or payment_hash") + return await self.send_encrypted_response(request_event.pubkey, json.dumps(response), request_event.id) + + status = None + if invoice and invoice.is_lightning(): + status = self.wallet.get_invoice_status(invoice) + if not invoice or status is None or status == PR_UNKNOWN: + response = self.get_error_response("NOT_FOUND", "Invoice not found") + return await self.send_encrypted_response(request_event.pubkey, json.dumps(response), request_event.id) + + direction = None + b11 = None + if self.wallet.get_invoice(invoice.rhash): + direction = "outgoing" + b11 = invoice.lightning_invoice + elif self.wallet.get_request(invoice.rhash): + direction = "incoming" + _, b11 = self.wallet.lnworker.get_bolt11_invoice( + payment_hash=bytes.fromhex(invoice.rhash), + amount_msat=invoice.amount_msat, + message=invoice.message, + expiry=invoice.exp, + fallback_address=None + ) + + response = { + "result_type": "lookup_invoice", + "result": { + "description": invoice.message, + "payment_hash": invoice.rhash, + "amount": invoice.get_amount_msat(), + "created_at": invoice.time, + "expires_at": invoice.get_expiration_date(), + "fees_paid": 0, + "metadata": {} + } + } + if payment_hash: # if client requested by payment hash we add the invoice + response['result']['invoice'] = b11 + + info = self.get_payment_info(invoice.rhash) + if info: + _, _, fee_msat, settled_at = info + if fee_msat: + response['result']['fees_paid'] = fee_msat + response['result']['settled_at'] = settled_at + + if direction: + response['result']['type'] = direction + if status == PR_PAID: + response['result']['preimage'] = self.wallet.lnworker.preimages.get(invoice.rhash, "not found") + self.logger.debug(f"lookup_invoice response: {response}") + await self.send_encrypted_response(request_event.pubkey, json.dumps(response), request_event.id) + + @log_exceptions + async def handle_get_balance(self, request_event: nEvent): + """ + https://github.com/nostr-protocol/nips/blob/75f246ed987c23c99d77bfa6aeeb1afb669e23f7/47.md#get_balance + """ + balance = int(self.wallet.lnworker.get_balance()) + response = { + "result_type": "get_balance", + "result": { + "balance": balance * 1000, + } + } + await self.send_encrypted_response(request_event.pubkey, json.dumps(response), request_event.id) + + @log_exceptions + async def handle_get_info(self, request_event: nEvent): + """ + https://github.com/nostr-protocol/nips/blob/75f246ed987c23c99d77bfa6aeeb1afb669e23f7/47.md#get_info + """ + height = self.wallet.lnworker.network.blockchain().height() + blockhash = self.wallet.lnworker.network.blockchain().get_hash(height) + response = { + "result_type": "get_info", + "result": { + "alias": self.config.LIGHTNING_NODE_ALIAS, + "color": self.config.LIGHTNING_NODE_COLOR_RGB, + "pubkey": self.wallet.lnworker.node_keypair.pubkey.hex(), + "network": net.NET_NAME, + "block_height": height, + "block_hash": blockhash, + "methods": self.SUPPORTED_METHODS, + } + } + if self.SUPPORTED_NOTIFICATIONS: + response['result']['notifications'] = self.SUPPORTED_NOTIFICATIONS + await self.send_encrypted_response(request_event.pubkey, json.dumps(response), request_event.id) + + @log_exceptions + async def handle_list_transactions(self, request_event: nEvent, params: dict): + """ + https://github.com/nostr-protocol/nips/blob/75f246ed987c23c99d77bfa6aeeb1afb669e23f7/47.md#list_transactions + Lists invoices and payments. If type is not specified, both invoices and payments are returned. + The from and until parameters are timestamps in seconds since epoch. + If from is not specified, it defaults to 0. If until is not specified, it defaults to the current time. + Transactions are returned in descending order of creation time. + """ + t0 = time.time() + from_ts = int(params.get('from', 0)) + until_ts = int(params.get('until', time.time())) + limit: Optional[int] = params.get('limit') + offset: Optional[int] = params.get('offset') + include_unpaid_reqs = bool(params.get('unpaid', False)) + # this is not in spec but alby go requests it + include_unpaid_outgoing = bool(params.get('unpaid_outgoing', False)) + req_type = params.get('type', "undefined") + + lightning_history = self.wallet.lnworker.get_lightning_history() + lightning_history = lightning_history.values() + + if req_type == "incoming": + lightning_history = [tx for tx in lightning_history if tx.direction == PaymentDirection.RECEIVED] + elif req_type == "outgoing": + lightning_history = [tx for tx in lightning_history if tx.direction == PaymentDirection.SENT] + else: + directions = [PaymentDirection.SENT, PaymentDirection.RECEIVED] + lightning_history = [tx for tx in lightning_history if tx.direction in directions] + + if include_unpaid_reqs: + requests = self.wallet.get_unpaid_requests() + for req in requests: + if not req.is_lightning() or not (from_ts <= req.time <= until_ts): + continue + lightning_history.append( + # append the payment request as LightingHistoryItem so they can be filtered + # together with the real lightning history items + LightningHistoryItem( + type='unpaid', + payment_hash=req.rhash, + preimage=None, + amount_msat=req.get_amount_msat() or 0, + fee_msat=None, + timestamp=req.time, + direction=PaymentDirection.RECEIVED, + group_id=None, + label=None + ) + ) + + if include_unpaid_outgoing: + """Alby Go requests unpaid_outgoing (out of nip47 spec) but then shows them as sent in the tx history. + So we only return PR_INFLIGHT here so its not totally misleading in the history.""" + invoices = self.wallet.get_invoices() + for inv in invoices: + if (not inv.is_lightning() + or not (from_ts <= inv.time <= until_ts) + or not self.wallet.get_invoice_status(inv) == PR_INFLIGHT): + continue + lightning_history.append( + LightningHistoryItem( + type='pending', + payment_hash=inv.rhash, + preimage=None, + amount_msat=inv.get_amount_msat() or 0, + fee_msat=None, + timestamp=inv.time, + direction=PaymentDirection.SENT, + group_id=None, + label=None + ) + ) + + if from_ts > 0 or until_ts < time.time() - 50: + # filter out transactions that are not in the time range + lightning_history = [tx for tx in lightning_history if from_ts <= tx.timestamp <= until_ts] + + lightning_history = sorted(lightning_history, key=lambda tx: tx.timestamp, reverse=True) + if offset and offset > 0: + lightning_history = lightning_history[offset:] + if limit and limit > 0: + lightning_history = lightning_history[:limit] + transactions = [] + for history_tx in lightning_history: + tx = { + "payment_hash": history_tx.payment_hash, + "amount": abs(history_tx.amount_msat), + "metadata": {}, + "fees_paid": 0 + } + payment: Optional[BaseInvoice] = None + if history_tx.direction == PaymentDirection.RECEIVED: + tx['type'] = "incoming" + payment = self.wallet.get_request(history_tx.payment_hash) + elif history_tx.direction == PaymentDirection.SENT: + tx['type'] = "outgoing" + payment = self.wallet.get_invoice(history_tx.payment_hash) + else: + tx['type'] = req_type + if payment: + if include_unpaid_outgoing and history_tx.type == 'pending': + tx['description'] = f"pending! {payment.message}" + else: + tx['description'] = payment.message + tx['expires_at'] = payment.get_expiration_date() + tx['created_at'] = payment.time + else: + # don't include txs with semi complete information as this will cause some clients + # to fail displaying any transaction at all + continue + if (not include_unpaid_reqs and not include_unpaid_outgoing) or history_tx.type == 'payment': + tx['settled_at'] = history_tx.timestamp + tx['preimage'] = history_tx.preimage + if history_tx.fee_msat: + tx['fees_paid'] = history_tx.fee_msat + transactions.append(tx) + + response = { + "result_type": "list_transactions", + "result": { + "transactions": transactions, + } + } + self.logger.debug(f"list_transactions: returning {len(transactions)} txs in {time.time() - t0:.2f}s") + await self.send_encrypted_response(request_event.pubkey, json.dumps(response), request_event.id) + + @event_listener + def on_event_request_status(self, wallet, key, status): + if wallet != self.wallet: + return + request: Optional[Request] = self.wallet.get_request(key) + if not request or not request.is_lightning() or not status == PR_PAID: + return + _, b11 = self.wallet.lnworker.get_bolt11_invoice( + payment_hash=request.payment_hash, + amount_msat=request.get_amount_msat(), + message=request.message, + expiry=request.exp, + fallback_address=None + ) + + payment_info = self.get_payment_info(request.rhash) + if payment_info: + _, _, _, settled_at = payment_info + else: + settled_at = None + + notification = { + "type": "incoming", + "invoice": b11, + "description": request.message, + "payment_hash": request.rhash, + "amount": request.get_amount_msat(), + "created_at": request.time, + "expires_at": request.get_expiration_date(), + "preimage": self.wallet.lnworker.preimages.get(request.rhash, "not found"), + "metadata": {}, + "fees_paid": 0 + } + if settled_at: + notification['settled_at'] = settled_at + + self.publish_notification_event({ + "notification_type": "payment_received", + "notification": notification, + }) + + @event_listener + def on_event_payment_succeeded(self, wallet, key): + if wallet != self.wallet: + return + invoice: Optional[Invoice] = self.wallet.get_invoice(key) + if not invoice or not invoice.is_lightning(): + return + + payment_info = self.get_payment_info(key) + if not payment_info: + return + _, fee_msat, _, settled_at = payment_info + + notification = { + "type": "outgoing", + "invoice": invoice.lightning_invoice or "", + "description": invoice.message, + "preimage": self.wallet.lnworker.preimages.get(key, "not found"), + "payment_hash": invoice.rhash, + "amount": invoice.get_amount_msat(), + "created_at": invoice.time, + "expires_at": invoice.get_expiration_date(), + "metadata": {} + } + if fee_msat: + notification['fees_paid'] = fee_msat + if settled_at: + notification['settled_at'] = settled_at + content = { + "notification_type": "payment_sent", + "notification": notification, + } + self.publish_notification_event(content) + + async def pay_invoice(self, b11: str, amount_msat: Optional[int], request_pub: str) -> dict: + try: + invoice: Invoice = Invoice.from_bech32(b11) + except InvoiceError: + return self.get_error_response("INTERNAL", "Invalid invoice") + + if invoice.get_amount_msat() is None and not amount_msat: + return self.get_error_response("INTERNAL", "Missing amount") + elif invoice.get_amount_msat() is None: + invoice.set_amount_msat(amount_msat) + + if not self.budget_allows_spend(request_pub, invoice.get_amount_sat()): + return self.get_error_response("QUOTA_EXCEEDED", "Payment exceeds daily limit") + + self.wallet.save_invoice(invoice) + try: + success, log = await self.wallet.lnworker.pay_invoice( + invoice=invoice, + amount_msat=amount_msat + ) + except Exception as e: + self.logger.exception(f"failed to pay nwc invoice") + return self.get_error_response("PAYMENT_FAILED", str(e)) + preimage: bytes = self.wallet.lnworker.get_preimage(bytes.fromhex(invoice.rhash)) + response = {} + if not success or not preimage: + return self.get_error_response("PAYMENT_FAILED", str(log)) + else: + self.add_to_budget(request_pub, invoice.get_amount_sat()) + response['result'] = { + 'preimage': preimage.hex(), + } + if success: + self.logger.info(f"paid invoice request from NWC for {invoice.get_amount_sat()} sat") + else: + self.logger.info(f"failed to pay invoice request from NWC: {log}") + return response + + def add_to_budget(self, client_pub: str, amount_sat: int) -> None: + """ + If client_pub has a budget, check if the amount is within the budget and add it to the budget. + Return True if the payment is allowed (within the budget) + """ + if 'budget_spends' not in self.connections[client_pub]: + self.connections[client_pub]['budget_spends'] = [] + # tuples don't work because jsondb converts them to lists on reload + self.connections[client_pub]['budget_spends'].append([amount_sat, int(time.time())]) + + def get_used_budget(self, client_pub: str) -> int: + """ + Returns the used budget for the given client_pubkey. + """ + if 'budget_spends' not in self.connections[client_pub]: + return 0 + used_budget: int = 0 + budget_spends = self.connections[client_pub]['budget_spends'] + for amount, timestamp in list(budget_spends): + if timestamp > int(time.time()) - 24 * 3600: + used_budget += amount + elif timestamp < int(time.time()) - 24 * 3600: + # remove old expense + try: + budget_spends.remove([amount, timestamp]) + except ValueError: + self.logger.debug("", exc_info=True) + continue # could happen if there is a race + return used_budget + + def budget_allows_spend(self, client_pub: str, sats_to_spend: int) -> bool: + client_budget_sat: Optional[int] = self.connections[client_pub].get('daily_limit_sat') + if client_budget_sat is None: + return True # unlimited budget + used_budget: int = self.get_used_budget(client_pub) + if used_budget + sats_to_spend > client_budget_sat: + return False + return True + + async def publish_info_event(self): + """ + Publishes the info event according to spec, announcing the supported methods. + We publish one info event for each client connection. + https://github.com/nostr-protocol/nips/blob/75f246ed987c23c99d77bfa6aeeb1afb669e23f7/47.md#example-nip-47-info-event + """ + content = ' '.join(self.SUPPORTED_METHODS) + if self.SUPPORTED_NOTIFICATIONS: + tags = [['notifications', ' '.join(self.SUPPORTED_NOTIFICATIONS)]] + else: + tags = None + for client_pubkey, connection in list(self.connections.items()): + event_id = await aionostr._add_event( + self.manager, + kind=self.INFO_EVENT_KIND, + tags=tags, # only needed if we support notification events + content=content, + private_key=connection['our_secret'] + ) + self.logger.debug(f"Published info event {event_id} to {client_pubkey}") + + def publish_notification_event(self, content: dict): + """ + https://github.com/nostr-protocol/nips/blob/75f246ed987c23c99d77bfa6aeeb1afb669e23f7/47.md#notification-events + """ + self.logger.debug(f"Publishing notification event: {content}") + for client_pubkey, connection in list(self.connections.items()): + coro = self.taskgroup.spawn(aionostr._add_event( + self.manager, + kind=self.NOTIFICATION_EVENT_KIND, + tags=[['p', client_pubkey]], + content=self.encrypt_to_pubkey(json.dumps(content), client_pubkey), + private_key=connection['our_secret'] + ) + ) + asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop()) + + def encrypt_to_pubkey(self, msg: str, pubkey: str) -> str: + """ + Encrypts the given message to the given pubkey using the connection secret. + """ + our_secret: str = self.connections[pubkey]['our_secret'] + our_secret_key = PrivateKey(raw_secret=bytes.fromhex(our_secret)) + encrypted_content: str = our_secret_key.encrypt_message(msg, pubkey) + return encrypted_content + + def get_payment_info(self, payment_hash: str) \ + -> Optional[Tuple[PaymentDirection, int, Optional[int], int]]: + payment_hash: bytes = bytes.fromhex(payment_hash) + payments = self.wallet.lnworker.get_payments(status='settled') + plist = payments.get(payment_hash) + if plist: + info = self.wallet.lnworker.get_payment_info(payment_hash) + if info: + dir, amount, fee, ts = self.wallet.lnworker.get_payment_value(info, plist) + fee = abs(fee) if fee else None + return dir, abs(amount), fee, ts + return None diff --git a/electrum/plugins/nwc/qt.py b/electrum/plugins/nwc/qt.py new file mode 100644 index 000000000..7d990e46d --- /dev/null +++ b/electrum/plugins/nwc/qt.py @@ -0,0 +1,289 @@ +from electrum.i18n import _ +from .nwcserver import NWCServerPlugin +from electrum.gui.qt.util import WindowModalDialog, Buttons, EnterButton, OkButton, CancelButton, \ + CloseButton +from electrum.gui.common_qt.util import paintQR +from electrum.plugin import hook +from functools import partial +from datetime import datetime + +from PyQt6.QtWidgets import QVBoxLayout, QPushButton, QLabel, QTreeWidget, QTreeWidgetItem, \ + QTextEdit, QApplication, QSpinBox, QSizePolicy, QComboBox, QLineEdit +from PyQt6.QtGui import QPixmap, QImage +from PyQt6.QtCore import Qt + +from typing import TYPE_CHECKING, Optional +if TYPE_CHECKING: + from electrum.wallet import Abstract_Wallet + from electrum.gui.qt.main_window import ElectrumWindow + from electrum.gui.qt import ElectrumGui + +class Plugin(NWCServerPlugin): + def __init__(self, *args): + NWCServerPlugin.__init__(self, *args) + self._init_qt_received = False + + @hook + def load_wallet(self, wallet: 'Abstract_Wallet', window: 'ElectrumWindow'): + self.start_plugin(wallet) + + @hook + def init_qt(self, gui: 'ElectrumGui'): + if self._init_qt_received: + return + self._init_qt_received = True + for w in gui.windows: + self.start_plugin(w.wallet) + + def requires_settings(self): + return True + + def settings_widget(self, window: WindowModalDialog): + return EnterButton(_('Setup'), + partial(self.settings_dialog, window)) + + def settings_dialog(self, window: WindowModalDialog): + wallet: 'Abstract_Wallet' = window.parent().wallet + if not wallet.has_lightning(): + window.show_error(_("{} plugin requires a lightning enabled wallet. Setup lightning first.") + .format("NWC")) + return + + d = WindowModalDialog(window, _("Nostr Wallet Connect")) + main_layout = QVBoxLayout(d) + + # Connections list + main_layout.addWidget(QLabel(_("Existing Connections:"))) + connections_list = QTreeWidget() + connections_list.setHeaderLabels([_("Name"), _("Budget [{}]").format(self.config.get_base_unit()), _("Expiry")]) + # Set the resize mode for all columns to adjust to content + header = connections_list.header() + header.setSectionResizeMode(0, header.ResizeMode.ResizeToContents) + header.setSectionResizeMode(1, header.ResizeMode.ResizeToContents) + header.setSectionResizeMode(2, header.ResizeMode.ResizeToContents) + header.setStretchLastSection(False) + # Set size policy to expand horizontally + connections_list.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) + # Make the widget update its size when data changes + connections_list.setAutoExpandDelay(0) + + def update_connections_list(): + # Clear the list and repopulate it + connections_list.clear() + connections = self.list_connections() + for name, conn in connections.items(): + if conn['valid_until'] == 'unset': + expiry = _("never") + else: + expiry = datetime.fromtimestamp(conn['valid_until']).isoformat(' ')[:-3] + if conn['daily_limit_sat'] == 'unset': + limit = _('unlimited') + else: + budget = self.config.format_amount(conn['daily_limit_sat']) + used = self.config.format_amount( + self.nwc_server.get_used_budget(conn['client_pub'])) + limit = f"{used}/{budget}" + item = QTreeWidgetItem( + [ + name, + limit, + expiry + ] + ) + connections_list.addTopLevelItem(item) + + update_connections_list() + connections_list.setMinimumHeight(min(connections_list.sizeHint().height(), 400)) + main_layout.addWidget(connections_list) + + # Delete button - initially disabled + delete_btn = QPushButton(_("Delete")) + delete_btn.setEnabled(False) + + # Function to delete the selected connection + def delete_selected_connection(): + selected_items = connections_list.selectedItems() + if not selected_items: + return + for item in selected_items: + try: + self.remove_connection(item.text(0)) + except ValueError: + self.logger.error(f"Failed to remove connection: {item.text(0)}") + return + update_connections_list() + if self.nwc_server: + self.nwc_server.restart_event_handler() + delete_btn.setEnabled(False) + + # Enable delete button when an item is selected + def on_item_selected(): + delete_btn.setEnabled(bool(connections_list.selectedItems())) + + connections_list.itemSelectionChanged.connect(on_item_selected) + delete_btn.clicked.connect(delete_selected_connection) + main_layout.addWidget(delete_btn) + + # Create Connection button + create_btn = QPushButton(_("Create Connection")) + def create_connection(): + # Show a dialog to create a new connection + connection_string = self.connection_info_input_dialog(window) + if connection_string: + update_connections_list() + self.show_new_connection_dialog(window, connection_string) + create_btn.clicked.connect(create_connection) + main_layout.addWidget(create_btn) + + # Add the info and close button to the footer + close_button = OkButton(d, label=_("Close")) + info_button = QPushButton(_("Info")) + info = _("This plugin allows you to create Nostr Wallet Connect connections and " + "remote control your wallet using Nostr NIP-47.") + warning = _("Most NWC clients only use a single of your relays, so ensure the relays accept your events.") + supported_methods = _("Supported NIP-47 methods: {}").format(", ".join(self.nwc_server.SUPPORTED_METHODS)) + info_msg = f"{info}\n\n{warning}\n\n{supported_methods}" + info_button.clicked.connect(lambda: window.show_message(info_msg)) + footer_buttons = Buttons( + info_button, + close_button, + ) + main_layout.addLayout(footer_buttons) + + d.setLayout(main_layout) + + # Resize the dialog to show the connections list properly + conn_list_width = sum(header.sectionSize(i) for i in range(header.count())) + d.resize(min(conn_list_width + 40, 600), d.height()) + return bool(d.exec()) + + def connection_info_input_dialog(self, window) -> Optional[str]: + # Create input dialog for connection parameters + input_dialog = WindowModalDialog(window, _("Enter NWC connection parameters")) + layout = QVBoxLayout(input_dialog) + + # Name field (mandatory) + layout.addWidget(QLabel(_("Connection Name (required):"))) + name_edit = QLineEdit() + name_edit.setMaximumHeight(30) + layout.addWidget(name_edit) + + # Daily limit field (optional) + layout.addWidget(QLabel(_("Daily Satoshi Budget (optional):"))) + limit_edit = OptionalSpinBox() + limit_edit.setRange(-1, 100_000_000) + limit_edit.setMaximumHeight(30) + layout.addWidget(limit_edit) + + # Validity period field (optional) + layout.addWidget(QLabel(_("Valid for seconds (optional):"))) + validity_edit = OptionalSpinBox() + validity_edit.setRange(-1, 63072000) + validity_edit.setMaximumHeight(30) + layout.addWidget(validity_edit) + + def change_nwc_relay(url): + self.config.NWC_RELAY = url + + # dropdown menu to select prioritized nwc relay from self.config.NOSTR_RELAYS + main_relay_label = QLabel(_("Main NWC Relay:")) + relay_tooltip = ( + _("Most clients only use the first relay url encoded in the connection string.") + + "\n" + _("The selected relay will be put first in the connection string.")) + main_relay_label.setToolTip(relay_tooltip) + layout.addWidget(main_relay_label) + relay_combo = QComboBox() + relay_combo.setMaximumHeight(30) + relay_combo.addItems(self.config.NOSTR_RELAYS.split(",")) + relay_combo.setCurrentText(self.config.NWC_RELAY) # type: ignore + relay_combo.currentTextChanged.connect(lambda: change_nwc_relay(relay_combo.currentText())) + layout.addWidget(relay_combo) + + # Buttons + buttons = Buttons(OkButton(input_dialog), CancelButton(input_dialog)) + layout.addLayout(buttons) + + if not input_dialog.exec(): + return None + + # Validate inputs + name = name_edit.text().strip() + if not name or len(name) < 1: + window.show_error(_("Connection name is required")) + return None + value_limit = limit_edit.value() if limit_edit.value() else None + duration_limit = validity_edit.value() if validity_edit.value() else None + + # Call create_connection function with user-provided parameters + try: + connection_string = self.create_connection( + name=name, + daily_limit_sat=value_limit, + valid_for_sec=duration_limit + ) + except ValueError as e: + window.show_error(str(e)) + return None + + if not connection_string: + window.show_error(_("Failed to create connection")) + return None + + return connection_string + + @staticmethod + def show_new_connection_dialog(window, connection_string: str): + # Create popup with QR code + popup = WindowModalDialog(window, _("New NWC Connection")) + vbox = QVBoxLayout(popup) + + qr: Optional[QImage] = paintQR(connection_string) + if not qr: + return + qr_pixmap = QPixmap.fromImage(qr) + qr_label = QLabel() + qr_label.setPixmap(qr_pixmap) + qr_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + vbox.addWidget(QLabel(_("Scan this QR code with your nwc client:"))) + vbox.addWidget(qr_label) + + # Add connection string text that can be copied + vbox.addWidget(QLabel(_("Or copy this connection string:"))) + text_edit = QTextEdit() + text_edit.setText(connection_string) + text_edit.setReadOnly(True) + text_edit.setMaximumHeight(80) + vbox.addWidget(text_edit) + + warning_label = QLabel(_("After closing this window you won't be able to " + "access the connection string again!")) + warning_label.setStyleSheet("color: red;") + warning_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + vbox.addWidget(warning_label) + + # Button to copy to clipboard + copy_btn = QPushButton(_("Copy to clipboard")) + copy_btn.clicked.connect(lambda: QApplication.clipboard().setText(connection_string)) + + vbox.addLayout(Buttons(copy_btn, CloseButton(popup))) + + popup.setLayout(vbox) + popup.exec() + + +class OptionalSpinBox(QSpinBox): + def __init__(self, parent=None): + super().__init__(parent) + self.setSpecialValueText(" ") + self.setMinimum(-1) + self.setValue(-1) + + def value(self): + # Return None if at special value, otherwise return the actual value + val = super().value() + return None if val == -1 else val + + def setValue(self, value): + # Accept None to set to the special empty value + super().setValue(-1 if value is None else value) diff --git a/electrum/plugins/revealer/qt.py b/electrum/plugins/revealer/qt.py index 82f636ce9..a23e01cb7 100644 --- a/electrum/plugins/revealer/qt.py +++ b/electrum/plugins/revealer/qt.py @@ -21,7 +21,6 @@ from functools import partial import sys from typing import TYPE_CHECKING -import qrcode from PyQt6.QtPrintSupport import QPrinter from PyQt6.QtCore import Qt, QRectF, QRect, QSizeF, QUrl, QPoint, QSize, QMarginsF from PyQt6.QtGui import (QPixmap, QImage, QBitmap, QPainter, QFontDatabase, QPen, QFont, @@ -37,6 +36,7 @@ from electrum.gui.qt.util import (read_QIcon, EnterButton, WWLabel, icon_path, i from electrum.gui.qt.qrtextedit import ScanQRTextEdit from electrum.gui.qt.main_window import StatusBarButton from electrum.gui.qt.util import read_QIcon_from_bytes, read_QPixmap_from_bytes +from electrum.gui.common_qt.util import paintQR from .revealer import RevealerPlugin @@ -741,7 +741,7 @@ class Plugin(RevealerPlugin): base_img.height()-total_distance_h - border_thick), Qt.AlignmentFlag.AlignRight, self.versioned_seed.checksum) # draw qr code - qr_qt = self.paintQR(self.versioned_seed.get_ui_string_version_plus_seed() + qr_qt = paintQR(self.versioned_seed.get_ui_string_version_plus_seed() + self.versioned_seed.checksum) target = QRectF(base_img.width()-65-qr_size, base_img.height()-65-qr_size, @@ -815,32 +815,6 @@ class Plugin(RevealerPlugin): return base_img - def paintQR(self, data): - if not data: - return - qr = qrcode.QRCode() - qr.add_data(data) - matrix = qr.get_matrix() - k = len(matrix) - border_color = Qt.GlobalColor.white - base_img = QImage(k * 5, k * 5, QImage.Format.Format_ARGB32) - base_img.fill(border_color) - qrpainter = QPainter() - qrpainter.begin(base_img) - boxsize = 5 - size = k * boxsize - left = (base_img.width() - size)//2 - top = (base_img.height() - size)//2 - qrpainter.setBrush(Qt.GlobalColor.black) - qrpainter.setPen(Qt.GlobalColor.black) - - for r in range(k): - for c in range(k): - if matrix[r][c]: - qrpainter.drawRect(left+c*boxsize, top+r*boxsize, boxsize - 1, boxsize - 1) - qrpainter.end() - return base_img - def calibration_dialog(self, window): d = WindowModalDialog(window, _("Revealer - Printer calibration settings")) diff --git a/electrum/simple_config.py b/electrum/simple_config.py index a0c12e980..a93b8764f 100644 --- a/electrum/simple_config.py +++ b/electrum/simple_config.py @@ -680,6 +680,7 @@ Warning: setting this to too low will result in lots of payment failures."""), ) LIGHTNING_NODE_ALIAS = ConfigVar('lightning_node_alias', default='', type_=str) + LIGHTNING_NODE_COLOR_RGB = ConfigVar('lightning_node_color_rgb', default='000000', type_=str) EXPERIMENTAL_LN_FORWARD_PAYMENTS = ConfigVar('lightning_forward_payments', default=False, type_=bool) EXPERIMENTAL_LN_FORWARD_TRAMPOLINE_PAYMENTS = ConfigVar('lightning_forward_trampoline_payments', default=False, type_=bool) TEST_FAIL_HTLCS_WITH_TEMP_NODE_FAILURE = ConfigVar('test_fail_htlcs_with_temp_node_failure', default=False, type_=bool) @@ -829,9 +830,9 @@ Warning: setting this to too low will result in lots of payment failures."""), # nostr NOSTR_RELAYS = ConfigVar( 'nostr_relays', - default='wss://nos.lol,wss://relay.damus.io,wss://brb.io,wss://nostr.mom,' + default='wss://relay.getalby.com/v1,wss://nos.lol,wss://relay.damus.io,wss://brb.io,' 'wss://relay.primal.net,wss://ftp.halifax.rwth-aachen.de/nostr,' - 'wss://eu.purplerelay.com,wss://nostr.einundzwanzig.space', + 'wss://eu.purplerelay.com,wss://nostr.einundzwanzig.space,wss://nostr.mom', type_=str, short_desc=lambda: _("Nostr relays"), long_desc=lambda: ' '.join([ diff --git a/electrum/util.py b/electrum/util.py index e4a56b44d..7a4451f9f 100644 --- a/electrum/util.py +++ b/electrum/util.py @@ -2307,6 +2307,7 @@ class LightningHistoryItem(NamedTuple): group_id: Optional[str] timestamp: int label: str + direction: Optional[int] def to_dict(self): return { 'type': self.type, @@ -2319,4 +2320,5 @@ class LightningHistoryItem(NamedTuple): 'preimage': self.preimage, 'group_id': self.group_id, 'ln_value': Satoshis(Decimal(self.amount_msat) / 1000), + 'direction': self.direction, } diff --git a/electrum/wallet.py b/electrum/wallet.py index c4216fdad..423f147c5 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -2984,10 +2984,14 @@ class Abstract_Wallet(ABC, Logger, EventListener): if write_to_disk: self.save_db() - def get_sorted_requests(self) -> List[Request]: - """ sorted by timestamp """ + def get_requests(self) -> List[Request]: out = [self.get_request(x) for x in self._receive_requests.keys()] out = [x for x in out if x is not None] + return out + + def get_sorted_requests(self) -> List[Request]: + """ sorted by timestamp """ + out = self.get_requests() out.sort(key=lambda x: x.time) return out