From 3bd3184eeeddde9a9eb7e124ec7492ee137f852b Mon Sep 17 00:00:00 2001 From: Brian Hartford Date: Mon, 25 Mar 2024 01:09:09 -0400 Subject: [PATCH] Support VPN tunnel + Send kind 9735 zap receipt (#4) --- .env | 3 +- menu.py | 3 +- requirements.txt | 4 +- src/lnurl.py | 0 src/nostr.py | 100 +++++++++++++++++++++++++++++ zap_server.py => src/zap_server.py | 82 +++++++++++++++++++++-- 6 files changed, 181 insertions(+), 11 deletions(-) create mode 100644 src/lnurl.py create mode 100644 src/nostr.py rename zap_server.py => src/zap_server.py (57%) diff --git a/.env b/.env index 4604862..6733f10 100644 --- a/.env +++ b/.env @@ -3,7 +3,8 @@ VPN_HOST=''# for you VPN client LND_REST_PORT=8080 #Default LND REST port is 8080, in most cases you can leave this untouched LND_INVOICE_MACAROON_HEX="" INTERNET_IDENTIFIER="" ## Add the value on the left side of your LNURL identifier for example if your LNURL identifier is "nabismo@nostpypy.lol" you would add "nabismo" here -HEX_PUBKEY=# +HEX_PUBKEY='' # +HEX_PRIV_KEY='' # DOMAIN="" # For example nostpy.lol CONTACT= #Enter your email address for the certbot command to get emails about your TLS certificate when it is near expiration NGINX_FILE_PATH=/etc/nginx/sites-available/default #Leave this untouched diff --git a/menu.py b/menu.py index b7adb5f..1a7d7d3 100644 --- a/menu.py +++ b/menu.py @@ -26,8 +26,7 @@ def start_flask_server(): print_color("\nStarting Zap Server", "33") subprocess.run(["python3", "-m", "venv", "zap_venv"], check=True) activate_cmd = ". zap_venv/bin/activate && " - commands = ["tmux new-session -s zap_server -d python zap_server.py"] - #commands = ["python zap_server.py"] + commands = ["tmux new-session -s zap_server -d python ./src/zap_server.py"] for cmd in commands: subprocess.run(["bash", "-c", activate_cmd + cmd], check=True) diff --git a/requirements.txt b/requirements.txt index 1cb1985..b8a3d77 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,6 @@ Flask==3.0.0 requests==2.31.0 PySocks==1.7.1 python-dotenv==0.19.2 -ddtrace \ No newline at end of file +ddtrace +secp256k1 +websocket-client \ No newline at end of file diff --git a/src/lnurl.py b/src/lnurl.py new file mode 100644 index 0000000..e69de29 diff --git a/src/nostr.py b/src/nostr.py new file mode 100644 index 0000000..f678815 --- /dev/null +++ b/src/nostr.py @@ -0,0 +1,100 @@ +import hashlib +import json +import logging +import secp256k1 +import time +from websocket import create_connection + + +class NostpyClient: + def __init__(self, relays, pubkey, privkey, nostr_event, response) -> None: + self.relays = relays + self.pubkey = pubkey + self.privkey = privkey + self.kind9734 = nostr_event + self.created_at = response["settle_date"] + self.zap_reciept_tags = [ + ["description", json.dumps(nostr_event)], + ["bolt11", response["payment_request"]], + ["preimage", response["r_preimage"]], + ] + + def sign_event_id(self, event_id: str, private_key_hex: str) -> str: + private_key = secp256k1.PrivateKey(bytes.fromhex(private_key_hex)) + sig = private_key.schnorr_sign( + bytes.fromhex(event_id), bip340tag=None, raw=True + ) + return sig.hex() + + def calc_event_id( + self, + public_key: str, + created_at: int, + kind_number: int, + tags: list, + content: str, + ) -> str: + data = [0, public_key, created_at, kind_number, tags, content] + data_str = json.dumps(data, separators=(",", ":"), ensure_ascii=False) + return hashlib.sha256(data_str.encode("UTF-8")).hexdigest() + + def parse_tags(self, logger): + try: + tag_list = [tag_pair for tag_pair in self.kind9734["tags"]] + tag_list.append(self.zap_reciept_tags) + return tag_list + except Exception as exc: + logger.error(f"Error parsing kind 9735 tags: {exc}") + + def create_event(self, kind_number, logger): + kind_9735_tags = self.parse_tags(logger) + content = "" + event_id = self.calc_event_id( + self.pubkey, self.created_at, kind_number, kind_9735_tags, content + ) + signature_hex = self.sign_event_id(event_id, self.privkey) + event_data = { + "id": event_id, + "pubkey": self.pubkey, + "kind": kind_number, + "created_at": self.created_at, + "tags": kind_9735_tags, + "content": content, + "sig": signature_hex, + } + + return event_data + + def verify_signature(self, event_id: str, pubkey: str, sig: str, logger) -> bool: + try: + pub_key = secp256k1.PublicKey(bytes.fromhex("02" + pubkey), True) + result = pub_key.schnorr_verify( + bytes.fromhex(event_id), bytes.fromhex(sig), None, raw=True + ) + if result: + logger.info(f"Verification successful for event: {event_id}") + else: + logger.error(f"Verification failed for event: {event_id}") + return result + except (ValueError, TypeError) as e: + logger.error(f"Error verifying signature for event {event_id}: {e}") + return False + + def send_event(self, ws_relay, logger): + try: + ws = create_connection(ws_relay) + logger.info("WebSocket connection created.") + event_data = self.create_event(9735, logger) + sig = event_data["sig"] + id = event_data["id"] + signature_valid = self.verify_signature(id, self.pubkey, sig, logger) + if signature_valid: + event_json = json.dumps(("EVENT", event_data)) + ws.send(event_json) + logger.debug(f"Event sent: {event_json}") + else: + logger.error("Invalid signature, event not sent.") + ws.close() + logger.info("WebSocket connection closed.") + except Exception as exc: + logger.error(f"Error sending ws event: {exc}") diff --git a/zap_server.py b/src/zap_server.py similarity index 57% rename from zap_server.py rename to src/zap_server.py index b819981..66a449b 100644 --- a/zap_server.py +++ b/src/zap_server.py @@ -2,12 +2,17 @@ import logging import os import socket +import time +import urllib.parse +import threading +import asyncio import requests import socks from dotenv import load_dotenv from ddtrace import tracer, patch_all -from flask import Flask, jsonify, request +from nostr import NostpyClient +from flask import Flask, jsonify, request, after_this_request app = Flask(__name__) @@ -23,6 +28,7 @@ LND_INVOICE_MACAROON_HEX = os.getenv("LND_INVOICE_MACAROON_HEX") INTERNET_IDENTIFIER = os.getenv("INTERNET_IDENTIFIER") HEX_PUBKEY = os.getenv("HEX_PUBKEY") +HEX_PRIV_KEY = os.getenv("HEX_PRIV_KEY") DOMAIN = os.getenv("DOMAIN") IDENTITY = INTERNET_IDENTIFIER.split("@")[0] @@ -34,8 +40,6 @@ ) logger = logging.getLogger(__name__) -logger.debug(f"ONion os {LND_ONION_ADDRESS}, VPN is {VPN_HOST}") - def make_http_request(url, headers, data): try: @@ -48,7 +52,7 @@ def make_http_request(url, headers, data): response.raise_for_status() invoice_data = response.json() logger.debug(f"Received invoice data: {invoice_data}") - return invoice_data["payment_request"] + return invoice_data["r_hash"], invoice_data["payment_request"] except requests.exceptions.RequestException as e: raise RuntimeError(f"Error making HTTP request: {e}") @@ -75,11 +79,59 @@ def get_invoice(amount, description): raise RuntimeError(f"Error creating invoice: {e}") +def check_invoice_payment(payment_request, max_attempts=20, sleep_time=1): + """ + Check if the specified invoice has been paid. + + Parameters: + - payment_request (str): The payment request of the invoice. + - max_attempts (int): The maximum number of attempts to check the payment. + - sleep_time (int): The time to sleep between each attempt. + + Returns: + - bool: True if the invoice has been paid, False otherwise. + """ + try: + attempts = 0 + while attempts < max_attempts: + # Encode payment request according to specified rules + encoded_payment_request = ( + f"payment_hash={payment_request.replace('+', '-').replace('/', '_')}" + ) + logger.debug(f"Attempt number {attempts}") + url = f"https://{VPN_HOST}:{LND_REST_PORT}/v2/invoices/lookup" # {encoded_payment_request}' + logger.debug(f"Sending request to {url}") + + response = requests.get( + url, + headers={"Grpc-Metadata-macaroon": LND_INVOICE_MACAROON_HEX}, + params=encoded_payment_request, + verify=False, + ) + response.raise_for_status() + invoice_status = response.json()["settled"] + if invoice_status: + logger.info("Invoice has been paid successfully.") + return response.json() + else: + logger.info("Invoice not yet paid. Retrying...") + attempts += 1 + time.sleep(sleep_time) + + logger.warning("Maximum attempts reached. Invoice may not have been paid.") + return False + + except requests.exceptions.RequestException as e: + logger.error(f"Error checking invoice payment: {e}") + raise + + @app.route("/lnurl-pay", methods=["GET"]) def lnurl_pay(): try: # Get parameters from the request or set default values logger.debug(f"Payload is {request}") + amount_satoshis = int( request.args.get("amount", 1000) ) # Default to 1000 millisatoshis (1 satoshi) @@ -88,11 +140,27 @@ def lnurl_pay(): description = request.args.get("comment") nostr_resp = request.args.get("nostr") if nostr_resp: - description = json.loads(nostr_resp).get("content") + nostr_event = json.loads(nostr_resp) + description = nostr_event["content"] + tags = nostr_event["tags"] + relays_value = next((item[1:] for item in tags if item[0] == "relays"), []) # Generate an invoice - payment_request = get_invoice(amount_millisatoshis, description) - logger.debug(f"Payment request is: {payment_request}") + r_hash, payment_request = get_invoice(amount_millisatoshis, description) + logger.debug(f"Payment request is: {payment_request} and r hash is {r_hash}") + + def call_functions() -> None: + check = check_invoice_payment(payment_request=r_hash) + if check: + lnurl_obj = NostpyClient( + relays_value, HEX_PUBKEY, HEX_PRIV_KEY, nostr_event, check + ) + for relay in lnurl_obj.relays: + lnurl_obj.send_event(relay, logger) + logger.debug(f"Event sent is {relay}") + + thread = threading.Thread(target=call_functions) + thread.start() return jsonify( {