Merge pull request #9675 from f321x/nwc
plugin: implement Nostr Wallet Connect (NIP47) plugin
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
53
electrum/plugins/nwc/__init__.py
Normal file
53
electrum/plugins/nwc/__init__.py
Normal file
@@ -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
|
||||
16
electrum/plugins/nwc/cmdline.py
Normal file
16
electrum/plugins/nwc/cmdline.py
Normal file
@@ -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)
|
||||
8
electrum/plugins/nwc/manifest.json
Normal file
8
electrum/plugins/nwc/manifest.json
Normal file
@@ -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"
|
||||
}
|
||||
913
electrum/plugins/nwc/nwcserver.py
Normal file
913
electrum/plugins/nwc/nwcserver.py
Normal file
@@ -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
|
||||
289
electrum/plugins/nwc/qt.py
Normal file
289
electrum/plugins/nwc/qt.py
Normal file
@@ -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)
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user